diff --git a/README.md b/README.md index 59775f06e..ca66da89c 100644 --- a/README.md +++ b/README.md @@ -55,20 +55,29 @@ redis-1st # 테이블 디자인 -- `Movie` (영화): 영화의 기본 정보 저장 -- `Theater` (상영관): 상영관 정보를 관리 -- `Screening` (상영 정보): movie_id, theater_id를 각각 외래키로 참조하며 영화와 상영관을 연결 -- `Seat` (좌석 정보): theater_id를 외래키로 참조하여 각 상영관의 좌석 정보 저장 - - `SeatNumber`를 Embedded 타입으로 구성해 좌석 번호 관련 로직을 분리 -- `ContentRating` (상영 등급)을 Enum 으로 작성해 잘못된 값 입력을 방지 -- 모든 엔티티는 `AuditingFields` 를 상속해 createdBy, createdAt, modifiedBy, modifiedAt 정보를 관리 +- members: 회원 정보를 저장 (이름, 이메일 등) +- movies: 영화 기본 정보를 저장 (제목, 장르, 개봉일 등) +- theaters: 상영관 정보를 저장 (상영관 이름) +- screenings: 특정 영화가 특정 상영관에서 언제 상영되는지 저장 (시작 시간, 종료 시간) +- seats: 각 극장의 좌석 정보를 저장 (총 25개 좌석) +- screeningSeat: 특정 상영 시간의 좌석을 관리하고 예약 여부를 체크 +- reservation: 회원이 특정 상영 시간에 대해 예약한 정보를 저장 +- reservedSeat: 예약(reservation)과 상영 좌석(screeningSeat)을 연결하는 N:M 관계 테이블 ### ️✔️️ 테이블 관계 설정 -``` -Screening (N) <-> Movie (1) -Screening (N) -> Theater (1) -Seat (N) -> Theater (1) -``` + +| 관계 | 설명 | +|------|------| +| `Screening (N) -> Movie (1)` | 하나의 영화(Movie)는 여러 상영(Screening)에서 사용될 수 있음. | +| `Screening (N) -> Theater (1)` | 하나의 극장(Theater)에서는 여러 상영(Screening)이 이루어질 수 있음. | +| `Seat (N) -> Theater (1)` | 하나의 극장(Theater)에는 여러 좌석(Seat)이 존재함. | +| `Reservation (N) -> Screening (1)` | 하나의 상영(Screening)에 대해 여러 개의 예약(Reservation)이 발생할 수 있음. | +| `Reservation (N) -> Member (1)` | 하나의 회원(Member)은 여러 개의 예약(Reservation)을 가질 수 있음. | +| `ScreeningSeat (N) -> Screening (1)` | 하나의 상영(Screening)은 여러 좌석(ScreeningSeat)과 연결됨. | +| `ScreeningSeat (N) -> Seat (1)` | 하나의 좌석(Seat)은 여러 상영(ScreeningSeat)에 포함될 수 있음. | +| `ReservedSeat (N) -> Reservation (1)` | 하나의 예약(Reservation)에는 여러 좌석(ReservedSeat)이 포함될 수 있음. | +| `ReservedSeat (N) -> ScreeningSeat (1)` | 하나의 좌석(ScreeningSeat)은 여러 예약(ReservedSeat)에서 사용될 수 있음. | + ### ✔️ 데이터 설정 - `v1.0__initial_schema.sql`로 DB 스키마를 정의 @@ -76,10 +85,19 @@ Seat (N) -> Theater (1) --- # 성능 테스트 +> 5,000명 사용자의 최대 부하를 견딜 수 있는지 10분 동안 테스트 +- **DAU**: 5,000명 +- **목적:** 하루 **5,000명 사용자가 피크 트래픽(최대 10배)** 상황에서 API 성능 검증 +- **부하 패턴:** **2분 동안 5,000명까지 증가 → 5분 유지 → 2분 유지 → 1분 종료** +- **성능 기준:** **상위 95% 요청이 응답 시간 200ms 이하, 실패율 1% 미만** +- **테스트 대상:** + - 전체 조회:`/api/v1/movies` + - 검색 조회: `/api/v1/movies?title='검색어'&genre='장르명'` + ## 1. 영화 목록 전체 조회 API +> JPQL로 쿼리 작성, Entity Graph 적용 ### 쿼리 -- JPQL로 쿼리 작성, Entity Graph 적용 ```sql Hibernate: select @@ -146,6 +164,7 @@ Hibernate: ### 부하 테스트 결과 ![img_3.png](doc/img_3.png) +- VU: 500 - **평균 응답 시간:** `7.14s` - **p(95) 응답 시간:** `14.28s` (목표 `200ms` 초과 ❌) - **실패율 (`http_req_failed`):** `0.00%` @@ -153,9 +172,8 @@ Hibernate: - **최대 응답 시간:** `35.61s` (일부 요청에서 매우 긴 응답 발생 ❌) ## 2. 검색 기능이 추가된 API (Index 적용 전) - +> 기존 전체 목록 조회에 검색 기능을 추가, QueryDSL로 동적 쿼리 작성 ### 쿼리 -- QueryDSL로 쿼리 작성 ```sql Hibernate: select @@ -208,37 +226,36 @@ Hibernate: | 1 | SIMPLE | m1\_0 | null | ALL | null | null | null | null | 503 | 1.11 | Using where; Using temporary; Using filesort | | 1 | SIMPLE | s1\_0 | null | ALL | null | null | null | null | 1501 | 100 | Using where; Using join buffer \(hash join\) | | 1 | SIMPLE | t1\_0 | null | eq\_ref | PRIMARY | PRIMARY | 4 | dev\_database.s1\_0.theater\_id | 1 | 100 | null | -- **`movie` 테이블** - - 인덱스를 사용하지 않고 **풀 테이블 스캔 (`ALL`)** 발생 - - 정렬 시 **임시 테이블(`Using temporary`) 및 파일 정렬(`Using filesort`)이 사용**되어 성능 저하 -- **`screening` 테이블** - - `movie_id` 컬럼에 적절한 인덱스가 없어 **풀 테이블 스캔 (`ALL`)** 발생 - - 조인 시 **해시 조인(`Using join buffer (hash join)`)이 사용**되어 성능 저하 -- **`theater` 테이블** - - 기본 키(`PRIMARY KEY`)를 사용한 **`eq_ref` 조인 방식** 적용 +- **`movie` 테이블**: 인덱스를 사용하지 않고 **Full Table Scan (`ALL`)** 발생 +- **`screening` 테이블**: `movie_id` 컬럼에 적절한 인덱스가 없어 **Full Table Scan (`ALL`)** 발생 +- **`theater` 테이블**: 기본 키(`PRIMARY KEY`)를 사용한 PK 기반 단일 조회 ### 부하 테스트 결과 ![img.png](doc/img.png) -- **평균 응답 시간 (`http_req_duration`)**: `1.95s` -- **p(95) 응답 시간**: `3.1s` +- **평균 응답 시간 (`http_req_duration`)**: `1.95s` +- **p(95) 응답 시간**: `3.1s` (목표 200ms 초과 ❌) - **최대 응답 시간**: `6.12s` - **실패율 (`http_req_failed`)**: `0.00%` - **초당 처리 요청 수 (`RPS`)**: `1437.79 req/s` - **총 요청 수**: `863,994` ## 3. Index 적용 후 - ### 적용한 인덱스 DDL ```jsx -CREATE INDEX idx_movie_release_date ON movie (release_date); -CREATE INDEX idx_screening_start_time ON screening (movie_id, start_time); -CREATE INDEX idx_movie_title ON movie (title) +-- 아래 두 가지 복합 인덱스를 생성 후 각각 차이를 확인 +-- 1. title, genre 순으로 복합 인덱스 생성 +CREATE INDEX idx_title_genre ON dev_database.movie (title, genre); +-- 2. genre, title 순으로 복합 인덱스 생성 +CREATE INDEX idx_genre_title ON dev_database.movie (genre, title); + +-- 모든 경우에 screening 테이블의 풀 스캔을 막기 위해 movie_id에 인덱스 생성 +CREATE INDEX idx_screening_movie_id ON screening (movie_id); ``` -### 쿼리 +### 3-1. ❌ Like 연산자 미사용 -**⭕️ Like 연산자 사용** -```sql +### 쿼리 +``` sql Hibernate: select m1_0.id, @@ -276,16 +293,41 @@ Hibernate: theater t1_0 on t1_0.id=s1_0.theater_id where - m1_0.title like ? escape '!' + m1_0.title=? and m1_0.genre=? order by m1_0.release_date desc, s1_0.start_time - ``` +### 실행 계획 -**❌ Like 연산자 미사용** -``` sql +- **복합 인덱스에서 `title`과 `genre`순서와 상관없이 동일한 실행 계획이 출력됨** + +| id | select\_type | table | partitions | type | possible\_keys | key | key\_len | ref | rows | filtered | Extra | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| 1 | SIMPLE | m1\_0 | null | ref | idx\_title\_genre | idx\_title\_genre | 603 | const,const | 1 | 100 | Using temporary; Using filesort | +| 1 | SIMPLE | s1\_0 | null | ref | idx\_screening\_movie\_id | idx\_screening\_movie\_id | 4 | dev\_database.m1\_0.id | 2 | 100 | null | +| 1 | SIMPLE | t1\_0 | null | eq\_ref | PRIMARY | PRIMARY | 4 | dev\_database.s1\_0.theater\_id | 1 | 100 | null | +#### 📌 실행 계획 분석 +- `movie` 테이블: title + genre 인덱스 사용, 1건 조회 (✅ 최적화됨) +- `screening` 테이블: movie_id 인덱스 사용, 2건 조회 (✅ 최적화됨) +- `theater` 테이블: 기본키 검색, 1건 조회 (✅ 완벽 최적화) +### 부하 테스트 결과 +![img_2.png](doc/img_2.png) +- **평균 응답 시간** (`http_req_duration`): 595.59ms +- **p(95) 응답 시간**: `1.3s` +- **최대 응답 시간**: `3.29s` +- 실패율 (`http_req_failed`): `0.00%` +- 초당 처리 요청 수 (`RPS`): `2659.89 req/s` +- 총 요청 수: `1,598,863` +- **✅ 평균 응답 속도가 69% 이상 감소하여 성능이 크게 개선** +- **✅ 처리량(RPS)과 총 요청 수도 약 85% 증가** +- **❌ 아직 p(95)가 1.3s로 목표(200ms)보다 높지만, 인덱스 사용 전보다 58% 감소** + +### 3-2. ⭕️ Like 연산자 사용 + +### 쿼리 +```sql Hibernate: select m1_0.id, @@ -323,66 +365,63 @@ Hibernate: theater t1_0 on t1_0.id=s1_0.theater_id where - m1_0.title=? + m1_0.title like ? escape '!' and m1_0.genre=? order by m1_0.release_date desc, s1_0.start_time + ``` -### 실행 계획 -**⭕️ Like 연산자 사용** - -| id | select\_type | table | partitions | type | possible\_keys | key | key\_len | ref | rows | filtered | Extra | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| 1 | SIMPLE | m1\_0 | null | range | idx\_movie\_title | idx\_movie\_title | 602 | null | 1 | 10 | Using index condition; Using where; Using temporary; Using filesort | -| 1 | SIMPLE | s1\_0 | null | ref | idx\_screening\_start\_time | idx\_screening\_start\_time | 4 | dev\_database.m1\_0.id | 2 | 100 | null | -| 1 | SIMPLE | t1\_0 | null | eq\_ref | PRIMARY | PRIMARY | 4 | dev\_database.s1\_0.theater\_id | 1 | 100 | null | -- **`movie` 테이블** - - **인덱스(`idx_movie_title`) 활용 (`range` 검색)** - - **임시 테이블(`Using temporary`) 및 파일 정렬(`Using filesort`) 발생** -- **`screening` 테이블** - - **`movie_id` 기반 `ref` 검색 → 인덱스(`idx_screening_start_time`) 정상 적용** -- **`theater` 테이블** - - 기본 키(`PRIMARY KEY`)를 사용한 **`eq_ref` 조인 방식** 적용 +### 실행 계획 -**❌ Like 연산자 미사용** +- **복합 인덱스에서 `title`과 `genre`순서와 상관없이 동일한 실행 계획이 출력됨** | id | select\_type | table | partitions | type | possible\_keys | key | key\_len | ref | rows | filtered | Extra | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| 1 | SIMPLE | m1\_0 | null | ref | idx\_movie\_title | idx\_movie\_title | 602 | const | 1 | 10 | Using where; Using temporary; Using filesort | -| 1 | SIMPLE | s1\_0 | null | ref | idx\_screening\_start\_time | idx\_screening\_start\_time | 4 | dev\_database.m1\_0.id | 2 | 100 | null | -| 1 | SIMPLE | t1\_0 | null | eq\_ref | PRIMARY | PRIMARY | 4 | dev\_database.s1\_0.theater\_id | 1 | 100 | null | -- **`movie` 테이블** - - **접근 방식 변경:** `range` → `ref` - - **`ref` 값 변경:** `null` → `const` + | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | + | 1 | SIMPLE | m1\_0 | null | range | idx\_title\_genre | idx\_title\_genre | 603 | null | 1 | 100 | Using index condition; Using temporary; Using filesort | + | 1 | SIMPLE | s1\_0 | null | ref | idx\_screening\_movie\_id | idx\_screening\_movie\_id | 4 | dev\_database.m1\_0.id | 2 | 100 | null | + | 1 | SIMPLE | t1\_0 | null | eq\_ref | PRIMARY | PRIMARY | 4 | dev\_database.s1\_0.theater\_id | 1 | 100 | null | + +#### 📌 실행 계획 분석 + +- **`movie` 테이블에서 `type = range` 사용** → ⚠️ **인덱스를 활용한 범위 검색이 적용됨** +- **`key = idx_title_genre (title, genre)`** → ✅ **복합 인덱스를 활용하여 검색 진행됨** +- **`Using index condition`이 발생** → ⚠️ **인덱스에서 일부 필터링을 수행했지만, 전체 필터링이 인덱스에서 해결되지 않았음** + - 일부 필터링은 인덱스에서 수행되었고, 나머지는 테이블 데이터를 조회하여 처리됨 + - 옵티마이저가 인덱스를 최적화하여 **Full Table Scan을 방지**한 것으로 보임 + +#### 📌 추가 최적화 가능성 +- 현재 `title` 검색에 `LIKE` 연산자를 사용하며 `title + genre` 복합 인덱스를 생성한 상태 +- Join 비용 절감을 위한 방안이었지만 검색키 세분화로 과도한 캐시키가 생성될 수 있는 상태 +1. `title` 또는 `genre` 단독 검색이 자주 발생할 가능성이 있다면, 단독 인덱스 추가 고려 + - **`genre` 단독 인덱스 (`idx_genre`) 추가** + - ✅ `WHERE genre = 'Action'`과 같은 단독 검색에서 범위 검색(`range`)이 아닌 **인덱스 검색(`ref`)이 적용될 수 있음** + - **`title` 단독 인덱스 (`idx_title`) 추가** + - 현재는 일반 B-TREE 인덱스(`idx_title`)를 추가해도 `LIKE '%검색어%'` 쿼리를 사용하기 때문에 인덱스 미적용 + - ✅`LIKE '검색어%'` (접두사 검색)으로 변경해 B-TREE 인덱스를 적용할 수 있음 + - ✅`FULLTEXT INDEX`를 적용해 `LIKE '%검색어%'` 검색 최적화 가능 +2. 캐싱을 도입한 최적화 고려 + - 장르 단위 캐싱 → 검색 범위를 좁혀 불필요한 쿼리 수행을 줄임 + - 전체 데이터 캐싱 후 필터링 → 응답 속도를 개선하고 데이터베이스 부하를 줄일 수 있음 ### 부하 테스트 결과 -**⭕️ Like 연산자 사용** -![img_1.png](doc/img_1.png) -- **평균 응답 시간 (`http_req_duration`)**: `1.24s` **(인덱스 적용 전과 비교했을 때 36.4% 감소 ✅)** -- **p(95) 응답 시간**: `2.43s` **(21.6% 감소 ✅)** -- **최대 응답 시간**: `5.25s` **(14.2% 감소 ✅)** +![img.png](doc/img_4.png) +- **평균 응답 시간 (`http_req_duration`)**: `1.24s` +- **p(95) 응답 시간**: `2.43s` +- **최대 응답 시간**: `5.25s` - **실패율 (`http_req_failed`)**: `0.00%` -- **초당 처리 요청 수 (`RPS`)**: `1895.07 req/s` **(31.7% 증가 ✅)** +- **초당 처리 요청 수 (`RPS`)**: `1895.07 req/s` - **총 요청 수**: `1,134,866` +- **⚠️ Like 연산자 미사용 결과와 비교했을 때 전반적인 성능 저하가 나타남** -**❌ Like 연산자 미사용** -![img_2.png](doc/img_2.png) -- **평균 응답 시간 (`http_req_duration`)**: `1.93s` **(like 사용할 때 보다 증가 ⬆️)** -- **p(95) 응답 시간**: `3.14s` **(증가 ⬆️)** -- **최대 응답 시간**: `6.27s` **(증가 ⬆️)** -- **실패율 (`http_req_failed`)**: `0.00%` **(성공)** -- **초당 요청 처리량 (`RPS`)**: `1449.83 req/s` **(감소 ⬇️)** -- **총 요청 수**: `871,069` **(감소 ⬇️)** - -# 4. 로컬 Caching 적용 후 +## 4. 로컬 Caching 적용 후 - Like 연산자 사용 + index 적용 -- + ### 캐싱한 데이터의 종류 - `title-genre` 조합에 해당하는 `List` 데이터를 캐싱 - **Key:** `title` + `genre` → `"in-SCI_FI"` 같은 조합 (Query Parameter 기반) @@ -391,8 +430,9 @@ Hibernate: ### 실행 계획 - 쿼리 실행 계획은 이전과 동일 + ### 부하 테스트 결과 -![img.png](img.png) +![img.png](doc/img.png) - **평균 응답 시간 (`http_req_duration`)**: `6.84ms` (**캐싱 적용 전보다 ⏬ 99.64% 감소**) - **p(95) 응답 시간**: `26.06ms` (**⏬ 99.2% 감소**) @@ -400,16 +440,32 @@ Hibernate: - **실패율 (`http_req_failed`)**: `0.00%` - **초당 요청 처리량 (`RPS`)**: `4206.66 req/s` (**⏫ 190% 증가**) - **총 요청 수**: `2,525,659` (**⏫ 190% 증가**) -# 5. 분산 Caching 적용 후 + + +## 5. 분산 Caching 적용 후 - Caffeine에서 Redis로 변경 - 캐싱한 데이터 종류, 실행 계획은 이전과 동일 ### 부하 테스트 결과 -![img_1.png](img_1.png) +![img_1.png](doc/img_1.png) - **평균 응답 시간 (`http_req_duration`)**: `13.7ms` **(로컬 캐싱 적용 결과 보다는 느려짐 ⬇️)** - **p(95) 응답 시간**: `53.58ms` - **최대 응답 시간**: `733.1ms` - **실패율 (`http_req_failed`)**: `0.00%` - **초당 요청 처리량 (`RPS`)**: `4177.76 req/s` -- **총 요청 수**: `2,510,158` \ No newline at end of file +- **총 요청 수**: `2,510,158` + +# 예약 생성 API 추가 +## 1. AOP 분산 락 적용 후 +![img.png](doc/img_aop.png) + +- 트랜잭션 실행 시간이 500ms ~ 750ms 으로 관찰됨 + - 이를 기반으로 leaseTime(잠금 유지 시간)을 실행 시간의 약 2배 + - waitTime(대기 시간)은 잠금 유지 시간의 2배로 설정 +- ⚠️ leaseTime 1.5초 + waitTime 3초 설정 시: 평균 응답 시간 9.68초 +- ✅ leaseTime 1초 + waitTime 2초 설정으로 변경: 평균 응답시간 `7.79초`로 감소 + +## 2. 함수형 분산 락 적용 후 +![img.png](doc/img_functional.png) +- 함수형 적용 후 평균 응답 시간(http_req_duration)이 AOP보다 약 20% 개선 \ No newline at end of file diff --git a/doc/img.png b/doc/img.png index 47664ab62..2486c42df 100644 Binary files a/doc/img.png and b/doc/img.png differ diff --git a/doc/img_1.png b/doc/img_1.png index e0b5e997e..012d319ac 100644 Binary files a/doc/img_1.png and b/doc/img_1.png differ diff --git a/doc/img_2.png b/doc/img_2.png index 5989aa729..0d680d4a5 100644 Binary files a/doc/img_2.png and b/doc/img_2.png differ diff --git a/doc/img_4.png b/doc/img_4.png new file mode 100644 index 000000000..5ee11e471 Binary files /dev/null and b/doc/img_4.png differ diff --git a/doc/img_aop.png b/doc/img_aop.png new file mode 100644 index 000000000..ed0d80df1 Binary files /dev/null and b/doc/img_aop.png differ diff --git a/doc/img_functional.png b/doc/img_functional.png new file mode 100644 index 000000000..12e303610 Binary files /dev/null and b/doc/img_functional.png differ diff --git a/img.png b/img.png deleted file mode 100644 index 2486c42df..000000000 Binary files a/img.png and /dev/null differ diff --git a/img_1.png b/img_1.png deleted file mode 100644 index 012d319ac..000000000 Binary files a/img_1.png and /dev/null differ diff --git a/movie-application/build.gradle b/movie-application/build.gradle index 9857671a8..70e62b09b 100644 --- a/movie-application/build.gradle +++ b/movie-application/build.gradle @@ -2,8 +2,11 @@ bootJar { enabled = false } jar { enabled = true } dependencies { - implementation project(':movie-domain') - implementation("org.springframework.boot:spring-boot-starter-web") - implementation ("org.springframework.boot:spring-boot-starter-data-jpa") - implementation("org.springframework.boot:spring-boot-starter-validation") + implementation project(':movie-domain') + implementation("org.springframework.boot:spring-boot-starter-web") + implementation ("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-validation") + + // Redis AOP 기반 분산 락 구현시 애노테이션 사용을 위해 추가 + implementation 'org.redisson:redisson-spring-boot-starter:3.22.0' } diff --git a/movie-application/src/main/java/com/example/application/dto/request/MovieSearchCriteria.java b/movie-application/src/main/java/com/example/application/dto/request/MovieSearchCriteria.java new file mode 100644 index 000000000..b8c45dbb5 --- /dev/null +++ b/movie-application/src/main/java/com/example/application/dto/request/MovieSearchCriteria.java @@ -0,0 +1,9 @@ +package com.example.application.dto.request; + +import com.example.domain.model.valueObject.Genre; + +public record MovieSearchCriteria( + String title, + Genre genre +) { +} diff --git a/movie-application/src/main/java/com/example/application/dto/request/ReservationRequestDto.java b/movie-application/src/main/java/com/example/application/dto/request/ReservationRequestDto.java new file mode 100644 index 000000000..03cf50c1f --- /dev/null +++ b/movie-application/src/main/java/com/example/application/dto/request/ReservationRequestDto.java @@ -0,0 +1,9 @@ +package com.example.application.dto.request; + +import java.util.List; + +public record ReservationRequestDto( + Long screeningId, + Long memberId, + List seatIds +) {} diff --git a/movie-application/src/main/java/com/example/application/dto/response/ReservationResponseDto.java b/movie-application/src/main/java/com/example/application/dto/response/ReservationResponseDto.java new file mode 100644 index 000000000..37f5de04b --- /dev/null +++ b/movie-application/src/main/java/com/example/application/dto/response/ReservationResponseDto.java @@ -0,0 +1,26 @@ +package com.example.application.dto.response; + +import com.example.domain.model.entity.Reservation; +import java.time.LocalTime; +import java.util.List; + +public record ReservationResponseDto( + String memberName, + String movieTitle, + String theaterName, + LocalTime screeningStartTime, + List reservedSeats +) { + public static ReservationResponseDto fromEntity(Reservation reservation) { + return new ReservationResponseDto( + reservation.getMember().getName(), + reservation.getScreening().getMovie().getTitle(), + reservation.getScreening().getTheater().getName(), + reservation.getScreening().getStartTime(), + reservation.getReservedSeats().stream() + .map(rs -> rs.getScreeningSeat().getSeat().getSeatNumber().toString()) + .toList() + ); + } +} + diff --git a/movie-application/src/main/java/com/example/application/exception/GlobalExceptionHandler.java b/movie-application/src/main/java/com/example/application/exception/GlobalExceptionHandler.java index f5b2cd46b..eb4d47159 100644 --- a/movie-application/src/main/java/com/example/application/exception/GlobalExceptionHandler.java +++ b/movie-application/src/main/java/com/example/application/exception/GlobalExceptionHandler.java @@ -1,8 +1,10 @@ package com.example.application.exception; import com.example.domain.exception.CustomException; +import com.example.domain.exception.ErrorCode; import com.example.domain.exception.ErrorResponse; import org.springframework.http.ResponseEntity; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -12,7 +14,7 @@ public class GlobalExceptionHandler { public ResponseEntity handleCustomException(CustomException ex) { ErrorResponse errorResponse = new ErrorResponse( ex.getErrorCode().name(), - ex.getErrorCode().getMessage(), + ex.getCustomMessage(), ex.getErrorCode().getStatusCode() ); return ResponseEntity.status(ex.getErrorCode().getStatusCode()).body(errorResponse); @@ -27,4 +29,15 @@ public ResponseEntity handleGeneralException(Exception ex) { ); return ResponseEntity.status(500).body(errorResponse); } + + @ExceptionHandler(ObjectOptimisticLockingFailureException.class) + public ResponseEntity handleOptimisticLockFailure(ObjectOptimisticLockingFailureException ex) { + ErrorResponse errorResponse = new ErrorResponse( + ErrorCode.OPTIMISTIC_LOCK_CONFLICT.name(), + ErrorCode.OPTIMISTIC_LOCK_CONFLICT.getMessage(), + ErrorCode.OPTIMISTIC_LOCK_CONFLICT.getStatusCode() + ); + return ResponseEntity.status(ErrorCode.OPTIMISTIC_LOCK_CONFLICT.getStatusCode()).body(errorResponse); + } + } diff --git a/movie-application/src/main/java/com/example/application/lock/DistributedLock.java b/movie-application/src/main/java/com/example/application/lock/DistributedLock.java new file mode 100644 index 000000000..eaa39e5ec --- /dev/null +++ b/movie-application/src/main/java/com/example/application/lock/DistributedLock.java @@ -0,0 +1,15 @@ +package com.example.application.lock; + +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(); + long waitTime() default 2_000; // 적당한 대기 시간을 고려해 잠금 시간의 2배 정도로 설정 + long leaseTime() default 1_000; // 지연을 고려해 트랜잭션 평균 실행 시간의 2배 정도로 설정 +} + diff --git a/movie-application/src/main/java/com/example/application/lock/DistributedLockAspect.java b/movie-application/src/main/java/com/example/application/lock/DistributedLockAspect.java new file mode 100644 index 000000000..918709443 --- /dev/null +++ b/movie-application/src/main/java/com/example/application/lock/DistributedLockAspect.java @@ -0,0 +1,46 @@ +package com.example.application.lock; + +import java.util.concurrent.TimeUnit; +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.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class DistributedLockAspect { + + private final RedissonClient redissonClient; + + @Around("@annotation(distributedLock)") + public Object applyLock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable { + String lockKey = distributedLock.key(); + long waitTime = distributedLock.waitTime(); + long leaseTime = distributedLock.leaseTime(); + + RLock lock = redissonClient.getLock(lockKey); + + boolean acquired = lock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS); + if (!acquired) { + log.warn("[Distributed Lock] 락 획득 실패: {}", lockKey); + throw new RuntimeException("Seat is already reserved by another transaction."); + } + + try { + log.info("[Distributed Lock] 락 획득 성공: {}", lockKey); + return joinPoint.proceed(); + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + log.info("[Distributed Lock] 락 해제 완료: {}", lockKey); + } + } + } + +} diff --git a/movie-application/src/main/java/com/example/application/lock/DistributedLockExecutor.java b/movie-application/src/main/java/com/example/application/lock/DistributedLockExecutor.java new file mode 100644 index 000000000..7eaadae2f --- /dev/null +++ b/movie-application/src/main/java/com/example/application/lock/DistributedLockExecutor.java @@ -0,0 +1,7 @@ +package com.example.application.lock; + +import java.util.function.Supplier; + +public interface DistributedLockExecutor { + T executeWithLock(String key, long waitTime, long leaseTime, Supplier task); +} \ No newline at end of file diff --git a/movie-application/src/main/java/com/example/application/lock/RedissonDistributedLockExecutor.java b/movie-application/src/main/java/com/example/application/lock/RedissonDistributedLockExecutor.java new file mode 100644 index 000000000..56b7fc983 --- /dev/null +++ b/movie-application/src/main/java/com/example/application/lock/RedissonDistributedLockExecutor.java @@ -0,0 +1,53 @@ +package com.example.application.lock; + +import com.example.domain.exception.CustomException; +import com.example.domain.exception.ErrorCode; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class RedissonDistributedLockExecutor implements DistributedLockExecutor { + + private final RedissonClient redissonClient; + + @Override + public T executeWithLock(String key, long waitTime, long leaseTime, Supplier task) { + RLock lock = redissonClient.getLock(key); + boolean acquired = false; + + try { + log.info("[락 시도] Key: {}, Thread: {}", key, Thread.currentThread().threadId()); + + acquired = lock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS); + + if (!acquired) { + log.warn("[락 획득 실패] Key: {}, Thread: {}", key, Thread.currentThread().threadId()); + throw new CustomException(ErrorCode.DISTRIBUTED_LOCK_FAILURE, "좌석 예약 요청이 많아 처리가 지연되었습니다. 다시 시도해주세요."); + } + log.info("[락 획득 성공] Key: {}, Thread: {}", key, Thread.currentThread().threadId()); + + return task.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("[ERROR] Lock acquisition interrupted", e); + } finally { + if (acquired && lock.isHeldByCurrentThread()) { + try { + lock.unlock(); + log.info("[락 해제] Key: {}, Thread: {}", key, Thread.currentThread().threadId()); + } catch (IllegalMonitorStateException ex) { + log.warn("[락 해제 실패: 강제 해제 시도] Key: {}", key); + lock.forceUnlock(); + } + } + } + } + +} diff --git a/movie-application/src/main/java/com/example/application/port/in/MessageServicePort.java b/movie-application/src/main/java/com/example/application/port/in/MessageServicePort.java new file mode 100644 index 000000000..8697ede0b --- /dev/null +++ b/movie-application/src/main/java/com/example/application/port/in/MessageServicePort.java @@ -0,0 +1,5 @@ +package com.example.application.port.in; + +public interface MessageServicePort { + void send(String message); +} diff --git a/movie-application/src/main/java/com/example/application/port/in/MovieServicePort.java b/movie-application/src/main/java/com/example/application/port/in/MovieServicePort.java index 1185d6029..3e5619029 100644 --- a/movie-application/src/main/java/com/example/application/port/in/MovieServicePort.java +++ b/movie-application/src/main/java/com/example/application/port/in/MovieServicePort.java @@ -1,13 +1,13 @@ package com.example.application.port.in; import com.example.application.dto.request.MovieRequestDto; +import com.example.application.dto.request.MovieSearchCriteria; import com.example.application.dto.request.ScreeningRequestDto; import com.example.application.dto.response.MovieResponseDto; -import com.example.domain.model.valueObject.Genre; import java.util.List; public interface MovieServicePort { - List findMovies(String title, Genre genre); + List findMovies(MovieSearchCriteria movieSearchCriteria); void createMovie(MovieRequestDto movieRequestDto); void addScreeningToMovie(Long movieId, ScreeningRequestDto screeningRequestDto); } diff --git a/movie-application/src/main/java/com/example/application/port/in/ReservationServicePort.java b/movie-application/src/main/java/com/example/application/port/in/ReservationServicePort.java new file mode 100644 index 000000000..f50c68289 --- /dev/null +++ b/movie-application/src/main/java/com/example/application/port/in/ReservationServicePort.java @@ -0,0 +1,8 @@ +package com.example.application.port.in; + +import com.example.application.dto.request.ReservationRequestDto; +import com.example.application.dto.response.ReservationResponseDto; + +public interface ReservationServicePort { + ReservationResponseDto create(ReservationRequestDto reservationRequestDto); +} diff --git a/movie-application/src/main/java/com/example/application/port/out/MemberRepositoryPort.java b/movie-application/src/main/java/com/example/application/port/out/MemberRepositoryPort.java new file mode 100644 index 000000000..affbcab91 --- /dev/null +++ b/movie-application/src/main/java/com/example/application/port/out/MemberRepositoryPort.java @@ -0,0 +1,8 @@ +package com.example.application.port.out; + +import com.example.domain.model.entity.Member; +import java.util.Optional; + +public interface MemberRepositoryPort { + Optional findById(Long id); +} diff --git a/movie-application/src/main/java/com/example/application/port/out/MovieRepositoryPort.java b/movie-application/src/main/java/com/example/application/port/out/MovieRepositoryPort.java index af916fb62..a1bb493ad 100644 --- a/movie-application/src/main/java/com/example/application/port/out/MovieRepositoryPort.java +++ b/movie-application/src/main/java/com/example/application/port/out/MovieRepositoryPort.java @@ -1,19 +1,13 @@ package com.example.application.port.out; +import com.example.application.dto.request.MovieSearchCriteria; import com.example.domain.model.entity.Movie; -import com.example.domain.model.valueObject.Genre; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Sort; -/** - * Represents a port for accessing movie data from a persistence layer. - * This interface provides an abstraction that allows the application layer - * to interact with various repository implementations - * (e.g., databases, external APIs). - */ public interface MovieRepositoryPort { - List findBy(String title, Genre genre, Sort sort); + List findBy(MovieSearchCriteria movieSearchCriteria, Sort sort); void save(Movie movie); Optional findById(Long id); } diff --git a/movie-application/src/main/java/com/example/application/port/out/ReservationRepositoryPort.java b/movie-application/src/main/java/com/example/application/port/out/ReservationRepositoryPort.java new file mode 100644 index 000000000..be2dc5d92 --- /dev/null +++ b/movie-application/src/main/java/com/example/application/port/out/ReservationRepositoryPort.java @@ -0,0 +1,13 @@ +package com.example.application.port.out; + +import com.example.domain.model.entity.Member; +import com.example.domain.model.entity.Reservation; +import com.example.domain.model.entity.Screening; +import java.util.Optional; + +public interface ReservationRepositoryPort { + void save(Reservation reservation); + Optional findById(Long id); + void deleteAll(); + int countByScreeningAndMember(Screening screening, Member member); +} diff --git a/movie-application/src/main/java/com/example/application/port/out/ReservationValidationPort.java b/movie-application/src/main/java/com/example/application/port/out/ReservationValidationPort.java new file mode 100644 index 000000000..029e2de6b --- /dev/null +++ b/movie-application/src/main/java/com/example/application/port/out/ReservationValidationPort.java @@ -0,0 +1,11 @@ +package com.example.application.port.out; + +import com.example.domain.model.entity.ScreeningSeat; +import com.example.domain.model.entity.Seat; +import java.util.List; + +public interface ReservationValidationPort { + void validateMaxSeatsPerScreening(int existingReservations, int newSeatCount); + void validateSeatsAreConsecutive(List seats); + void validateSeatsExist(List requestedSeatIds, List foundSeats); +} diff --git a/movie-application/src/main/java/com/example/application/port/out/ReservedSeatRepositoryPort.java b/movie-application/src/main/java/com/example/application/port/out/ReservedSeatRepositoryPort.java new file mode 100644 index 000000000..a2935295d --- /dev/null +++ b/movie-application/src/main/java/com/example/application/port/out/ReservedSeatRepositoryPort.java @@ -0,0 +1,8 @@ +package com.example.application.port.out; + +import com.example.domain.model.entity.ReservedSeat; +import java.util.List; + +public interface ReservedSeatRepositoryPort { + void saveAll(List reservedSeat); +} diff --git a/movie-application/src/main/java/com/example/application/port/out/ScreeningRepositoryPort.java b/movie-application/src/main/java/com/example/application/port/out/ScreeningRepositoryPort.java index 4031dcf60..535888e74 100644 --- a/movie-application/src/main/java/com/example/application/port/out/ScreeningRepositoryPort.java +++ b/movie-application/src/main/java/com/example/application/port/out/ScreeningRepositoryPort.java @@ -1,13 +1,9 @@ package com.example.application.port.out; import com.example.domain.model.entity.Screening; +import java.util.Optional; -/** - * Represents a port for accessing movie data from a persistence layer. - * This interface provides an abstraction that allows the application layer - * to interact with various repository implementations - * (e.g., databases, external APIs). - */ public interface ScreeningRepositoryPort { void save(Screening screening); + Optional findById(Long id); } diff --git a/movie-application/src/main/java/com/example/application/port/out/ScreeningSeatRepositoryPort.java b/movie-application/src/main/java/com/example/application/port/out/ScreeningSeatRepositoryPort.java new file mode 100644 index 000000000..f5945d12c --- /dev/null +++ b/movie-application/src/main/java/com/example/application/port/out/ScreeningSeatRepositoryPort.java @@ -0,0 +1,13 @@ +package com.example.application.port.out; + +import com.example.domain.model.entity.Screening; +import com.example.domain.model.entity.ScreeningSeat; +import java.util.List; + +public interface ScreeningSeatRepositoryPort { + List findByScreeningAndSeatIds(Screening screening, List seatIds); + void saveAndFlush(ScreeningSeat screeningSeat); + long count(); + void resetAllReservations(); + long countReservedSeats(Long screeningId); +} diff --git a/movie-application/src/main/java/com/example/application/port/out/SeatRepositoryPort.java b/movie-application/src/main/java/com/example/application/port/out/SeatRepositoryPort.java new file mode 100644 index 000000000..d88e33f61 --- /dev/null +++ b/movie-application/src/main/java/com/example/application/port/out/SeatRepositoryPort.java @@ -0,0 +1,4 @@ +package com.example.application.port.out; + +public interface SeatRepositoryPort { +} diff --git a/movie-application/src/main/java/com/example/application/port/out/TheaterRepositoryPort.java b/movie-application/src/main/java/com/example/application/port/out/TheaterRepositoryPort.java index caacd66b4..37c03a712 100644 --- a/movie-application/src/main/java/com/example/application/port/out/TheaterRepositoryPort.java +++ b/movie-application/src/main/java/com/example/application/port/out/TheaterRepositoryPort.java @@ -3,12 +3,6 @@ import com.example.domain.model.entity.Theater; import java.util.Optional; -/** - * Represents a port for accessing movie data from a persistence layer. - * This interface provides an abstraction that allows the application layer - * to interact with various repository implementations - * (e.g., databases, external APIs). - */ public interface TheaterRepositoryPort { Optional findById(Long id); } diff --git a/movie-application/src/main/java/com/example/application/service/MessageService.java b/movie-application/src/main/java/com/example/application/service/MessageService.java new file mode 100644 index 000000000..f6a5678ca --- /dev/null +++ b/movie-application/src/main/java/com/example/application/service/MessageService.java @@ -0,0 +1,23 @@ +package com.example.application.service; + +import com.example.application.port.in.MessageServicePort; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class MessageService implements MessageServicePort { + + @Override + public void send(String message) { + try { + log.info("[MessageService] 전송 시작: {}", message); + Thread.sleep(500); // 메시지 전송에 걸리는 시간 가정 + log.info("[MessageService] 전송 완료: {}", message); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); // 현재 스레드가 인터럽트 상태임을 유지하도록 설정 + log.error("[MessageService] 전송 실패!", e); + } + } + +} diff --git a/movie-application/src/main/java/com/example/application/service/MovieService.java b/movie-application/src/main/java/com/example/application/service/MovieService.java index 8f57c7167..e68990195 100644 --- a/movie-application/src/main/java/com/example/application/service/MovieService.java +++ b/movie-application/src/main/java/com/example/application/service/MovieService.java @@ -1,6 +1,7 @@ package com.example.application.service; import com.example.application.dto.request.MovieRequestDto; +import com.example.application.dto.request.MovieSearchCriteria; import com.example.application.dto.request.ScreeningRequestDto; import com.example.application.dto.response.MovieResponseDto; import com.example.application.port.in.MovieServicePort; @@ -12,7 +13,6 @@ import com.example.domain.model.entity.Movie; import com.example.domain.model.entity.Screening; import com.example.domain.model.entity.Theater; -import com.example.domain.model.valueObject.Genre; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.Cacheable; @@ -27,12 +27,13 @@ public class MovieService implements MovieServicePort { private final ScreeningRepositoryPort screeningRepositoryPort; private final TheaterRepositoryPort theaterRepositoryPort; - @Cacheable(value = "movies", key = "#title + '-' + #genre") + @Cacheable(value = "movies", key = "#movieSearchCriteria.title + '-' + #movieSearchCriteria.genre") @Override - public List findMovies(String title, Genre genre) { + public List findMovies(MovieSearchCriteria movieSearchCriteria) { Sort sort = Sort.by("releaseDate").descending(); - return movieRepositoryPort.findBy(title, genre, sort).stream() + return movieRepositoryPort.findBy(movieSearchCriteria, sort + ).stream() .map(MovieResponseDto::fromEntity) .toList(); } diff --git a/movie-application/src/main/java/com/example/application/service/ReservationService.java b/movie-application/src/main/java/com/example/application/service/ReservationService.java new file mode 100644 index 000000000..07b55bbd3 --- /dev/null +++ b/movie-application/src/main/java/com/example/application/service/ReservationService.java @@ -0,0 +1,143 @@ +package com.example.application.service; + +import com.example.application.dto.request.ReservationRequestDto; +import com.example.application.dto.response.ReservationResponseDto; +import com.example.application.lock.DistributedLockExecutor; +import com.example.application.port.in.MessageServicePort; +import com.example.application.port.in.ReservationServicePort; +import com.example.application.port.out.MemberRepositoryPort; +import com.example.application.port.out.ReservationRepositoryPort; +import com.example.application.port.out.ReservationValidationPort; +import com.example.application.port.out.ReservedSeatRepositoryPort; +import com.example.application.port.out.ScreeningRepositoryPort; +import com.example.application.port.out.ScreeningSeatRepositoryPort; +import com.example.domain.exception.CustomException; +import com.example.domain.exception.ErrorCode; +import com.example.domain.model.entity.Member; +import com.example.domain.model.entity.Reservation; +import com.example.domain.model.entity.ReservedSeat; +import com.example.domain.model.entity.Screening; +import com.example.domain.model.entity.ScreeningSeat; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +@Slf4j +@RequiredArgsConstructor +@Service +public class ReservationService implements ReservationServicePort { + + private final ReservationRepositoryPort reservationRepositoryPort; + private final MemberRepositoryPort memberRepositoryPort; + private final ScreeningRepositoryPort screeningRepositoryPort; + private final ScreeningSeatRepositoryPort screeningSeatRepositoryPort; + private final ReservedSeatRepositoryPort reservedSeatRepositoryPort; + + private final ReservationValidationPort reservationValidationPort; + + private final MessageServicePort messageServicePort; + private final DistributedLockExecutor distributedLockExecutor; + private final TransactionTemplate transactionTemplate; + + @Override + public ReservationResponseDto create(ReservationRequestDto request) { + Screening screening = getScreening(request.screeningId()); + Member member = getMember(request.memberId()); + + List requestedSeats = validateReservationConstraints(screening, member, request.seatIds()); + + // 함수형 분산 락을 특정 메서드에 적용 + String lockKey = "seat_reservation:" + request.screeningId(); + long waitTime = 5_000; // 락 획득까지 대기할 시간 + long leaseTime = 5_000; // STW 발생 시 대기를 고려해 충분한 시간으로 설정 + + Reservation reservation = null; + try { + reservation = distributedLockExecutor.executeWithLock(lockKey, waitTime, leaseTime, + () -> transactionTemplate.execute(status -> + saveReservationAndSeats(screening, member, requestedSeats) + ) + ); + } catch (ObjectOptimisticLockingFailureException e) { + log.warn("[낙관락 예외 발생] 좌석 예약 실패 - 사용자 {}, {}", member.getId(), e.getMessage()); + } + + // 예약 실패 시 예외 처리 + if (reservation == null) { + throw new CustomException(ErrorCode.RESERVATION_REQUEST_FAILED); + } + + // 예약 성공 시 알림 전송 + sendReservationConfirmation(reservation.getMember(), requestedSeats, screening); + log.info("[예약 성공] 사용자명: {}, 좌석: {}", reservation.getMember().getName(), + reservation.getReservedSeats().stream() + .map(reservedSeat -> reservedSeat.getScreeningSeat().getSeat().getSeatNumber().toString()) + .collect(Collectors.joining(","))); + return ReservationResponseDto.fromEntity(reservation); + } + + /** 상영 정보 조회 */ + private Screening getScreening(Long screeningId) { + return screeningRepositoryPort.findById(screeningId) + .orElseThrow(() -> new CustomException(ErrorCode.INVALID_SCREENING)); + } + + /** 회원 정보 조회 */ + private Member getMember(Long memberId) { + return memberRepositoryPort.findById(memberId) + .orElseThrow(() -> new CustomException(ErrorCode.INVALID_MEMBER)); + } + + /** 예약 전 비즈니스 로직 기반으로 요청 값 검증 */ + private List validateReservationConstraints(Screening screening, Member member, List seatIds) { + // 요청된 좌석이 존재하는지 조회 + List requestedSeats = screeningSeatRepositoryPort.findByScreeningAndSeatIds(screening, seatIds); + + // 요청된 좌석 ID 목록과 조회된 좌석 리스트를 검증 레이어에서 검증 + reservationValidationPort.validateSeatsExist(seatIds, requestedSeats); + + // 해당 회원이 이미 예약한 좌석 수 확인 + int existingReservations = reservationRepositoryPort.countByScreeningAndMember(screening, member); + reservationValidationPort.validateMaxSeatsPerScreening(existingReservations, requestedSeats.size()); + + // 좌석이 연속된 형태인지 검증 + reservationValidationPort.validateSeatsAreConsecutive(requestedSeats.stream().map(ScreeningSeat::getSeat).toList()); + + return requestedSeats; + } + + /** 예약 및 좌석 저장 */ + private Reservation saveReservationAndSeats(Screening screening, Member member, List requestedSeats) { + Reservation reservation = Reservation.of(screening, member); + reservationRepositoryPort.save(reservation); + + List reservedSeats = new ArrayList<>(); + + for (ScreeningSeat screeningSeat : requestedSeats) { + // 좌석에 Optimistic Lock 적용하면서 예약된 좌석인지 확인 + screeningSeat.reserve(); // 좌석을 예약 상태로 변경 + screeningSeatRepositoryPort.saveAndFlush(screeningSeat); // 버전 변경 유도 + ReservedSeat reservedSeat = ReservedSeat.of(reservation, screeningSeat); + reservedSeats.add(reservedSeat); + } + + reservedSeatRepositoryPort.saveAll(reservedSeats); // 예약한 좌석 리스트 한 번에 추가 + reservation.addReservedSeats(reservedSeats); // 예약 정보에 예약한 좌석 리스트 추가 + + return reservation; + } + + /** 예약 완료 후 알림 전송 */ + private void sendReservationConfirmation(Member member, List requestedSeats, Screening screening) { + messageServicePort.send(String.format("[예약 완료] 사용자명: %s, 좌석: %s, 영화: %s, 상영관: %s, 시간: %s", + member.getName(), + requestedSeats.stream().map(ss -> ss.getSeat().getSeatNumber().toString()).collect(Collectors.joining(", ")), + screening.getMovie().getTitle(), screening.getTheater().getName(), screening.getStartTime())); + } + +} diff --git a/movie-domain/src/main/java/com/example/domain/exception/CustomException.java b/movie-domain/src/main/java/com/example/domain/exception/CustomException.java index ab67dde75..8aac43991 100644 --- a/movie-domain/src/main/java/com/example/domain/exception/CustomException.java +++ b/movie-domain/src/main/java/com/example/domain/exception/CustomException.java @@ -4,16 +4,20 @@ @Getter public class CustomException extends RuntimeException { + private final ErrorCode errorCode; + private final String customMessage; - public CustomException(ErrorCode errorCode) { - super(errorCode.getMessage()); + public CustomException(ErrorCode errorCode, String customMessage) { + super(customMessage); this.errorCode = errorCode; + this.customMessage = customMessage; } - public CustomException(ErrorCode errorCode, String customMessage) { - super(customMessage); + public CustomException(ErrorCode errorCode) { + super(errorCode.getMessage()); this.errorCode = errorCode; + this.customMessage = errorCode.getMessage(); } } diff --git a/movie-domain/src/main/java/com/example/domain/exception/ErrorCode.java b/movie-domain/src/main/java/com/example/domain/exception/ErrorCode.java index 372f3a79c..d6a2d1480 100644 --- a/movie-domain/src/main/java/com/example/domain/exception/ErrorCode.java +++ b/movie-domain/src/main/java/com/example/domain/exception/ErrorCode.java @@ -7,13 +7,25 @@ @Getter public enum ErrorCode { - INVALID_SEAT_NUMBER("Seat row must be between A-E and column must be between 1-5.", 400), - INVALID_TITLE("Movie title cannot be empty.", 400), + // 영화 정보 관련 에러 INVALID_TITLE_LENGTH("Movie title cannot be more than 50 characters.", 400), INVALID_CONTENT_RATING( "Content rating must be provided.", 400), INVALID_GENRE("Genre cannot be empty.", 400), INVALID_MOVIE("Movie must be provided.", 400), INVALID_THEATER("Theater must be provided.", 400), + + // 예약 관련 에러 + INVALID_SCREENING("Screening not found.", 400), + INVALID_MEMBER("Member not found.", 400), + MAX_SEATS_EXCEEDED("Cannot reserve more than 5 seats per screening, including previous reservations.", 400), + SEAT_ALREADY_RESERVED("Some seats are already reserved.", 400), + SEATS_NOT_CONSECUTIVE("Seats must be consecutive in the same row.", 400), + INVALID_REQUEST("Invalid request.", 400), + RESERVATION_REQUEST_FAILED("Reservation request failed.", 400), + + // 동시성 제어 관련 에러 + OPTIMISTIC_LOCK_CONFLICT("Optimistic lock conflict. Please try again.", 409), + DISTRIBUTED_LOCK_FAILURE("Distributed lock acquisition failed. Please try again.", 423) ; private final String message; diff --git a/movie-domain/src/main/java/com/example/domain/model/entity/Member.java b/movie-domain/src/main/java/com/example/domain/model/entity/Member.java new file mode 100644 index 000000000..57a419186 --- /dev/null +++ b/movie-domain/src/main/java/com/example/domain/model/entity/Member.java @@ -0,0 +1,34 @@ +package com.example.domain.model.entity; + +import com.example.domain.model.base.AuditingFields; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Getter; + +@Getter +@Entity +public class Member extends AuditingFields { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50) private String name; + @Column(nullable = false, length = 150, unique = true) private String email; + + + protected Member() {} + + private Member(String name, String email) { + this.name = name; + this.email = email; + } + + public static Member of(String name, String email) { + return new Member(name, email); + } + +} diff --git a/movie-domain/src/main/java/com/example/domain/model/entity/Reservation.java b/movie-domain/src/main/java/com/example/domain/model/entity/Reservation.java new file mode 100644 index 000000000..917b2fdf6 --- /dev/null +++ b/movie-domain/src/main/java/com/example/domain/model/entity/Reservation.java @@ -0,0 +1,51 @@ +package com.example.domain.model.entity; + +import com.example.domain.model.base.AuditingFields; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; +import lombok.Getter; + +@Getter +@Entity +public class Reservation extends AuditingFields { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(nullable = false) + private Screening screening; + + @ManyToOne + @JoinColumn(nullable = false) + private Member member; + + @OneToMany(mappedBy = "reservation", cascade = CascadeType.ALL) + private List reservedSeats = new ArrayList<>(); + + + protected Reservation() {} + + private Reservation(Screening screening, Member member) { + this.screening = screening; + this.member = member; + } + + public static Reservation of(Screening screening, Member member) { + return new Reservation(screening, member); + } + + public void addReservedSeats(List seats) { + this.reservedSeats.addAll(seats); + } + +} diff --git a/movie-domain/src/main/java/com/example/domain/model/entity/ReservedSeat.java b/movie-domain/src/main/java/com/example/domain/model/entity/ReservedSeat.java new file mode 100644 index 000000000..e1426165d --- /dev/null +++ b/movie-domain/src/main/java/com/example/domain/model/entity/ReservedSeat.java @@ -0,0 +1,36 @@ +package com.example.domain.model.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import lombok.Getter; + +@Getter +@Entity +public class ReservedSeat { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + private Reservation reservation; + + @ManyToOne + private ScreeningSeat screeningSeat; + + + protected ReservedSeat() {} + + private ReservedSeat(Reservation reservation, ScreeningSeat screeningSeat) { + this.reservation = reservation; + this.screeningSeat = screeningSeat; + } + + public static ReservedSeat of(Reservation reservation, ScreeningSeat screeningSeat) { + return new ReservedSeat(reservation, screeningSeat); + } + +} diff --git a/movie-domain/src/main/java/com/example/domain/model/entity/Screening.java b/movie-domain/src/main/java/com/example/domain/model/entity/Screening.java index 1372cd269..fd35fcfbc 100644 --- a/movie-domain/src/main/java/com/example/domain/model/entity/Screening.java +++ b/movie-domain/src/main/java/com/example/domain/model/entity/Screening.java @@ -1,6 +1,7 @@ package com.example.domain.model.entity; import com.example.domain.model.base.AuditingFields; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -8,7 +9,9 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; import java.time.LocalTime; +import java.util.List; import lombok.Getter; @Getter @@ -30,6 +33,9 @@ public class Screening extends AuditingFields { @JoinColumn(nullable = false) private Theater theater; + @OneToMany(mappedBy = "screening", cascade = CascadeType.ALL) + private List screeningSeats; + protected Screening() {} diff --git a/movie-domain/src/main/java/com/example/domain/model/entity/ScreeningSeat.java b/movie-domain/src/main/java/com/example/domain/model/entity/ScreeningSeat.java new file mode 100644 index 000000000..16bcb7288 --- /dev/null +++ b/movie-domain/src/main/java/com/example/domain/model/entity/ScreeningSeat.java @@ -0,0 +1,54 @@ +package com.example.domain.model.entity; + +import com.example.domain.exception.CustomException; +import com.example.domain.exception.ErrorCode; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Version; +import lombok.Getter; + +@Getter +@Entity +public class ScreeningSeat { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(nullable = false) + private Screening screening; + + @ManyToOne + @JoinColumn(nullable = false) + private Seat seat; + + private boolean reserved; + + @Version private int version; // 낙관적 락 적용 + + + protected ScreeningSeat() {} + + private ScreeningSeat(Screening screening, Seat seat) { + this.screening = screening; + this.seat = seat; + this.reserved = false; + } + + public static ScreeningSeat of(Screening screening, Seat seat) { + return new ScreeningSeat(screening, seat); + } + + public void reserve() { + if (reserved) { + throw new CustomException(ErrorCode.SEAT_ALREADY_RESERVED); + } + this.reserved = true; + } + +} diff --git a/movie-domain/src/main/java/com/example/domain/model/entity/Seat.java b/movie-domain/src/main/java/com/example/domain/model/entity/Seat.java index 60b96717d..7174ac0c8 100644 --- a/movie-domain/src/main/java/com/example/domain/model/entity/Seat.java +++ b/movie-domain/src/main/java/com/example/domain/model/entity/Seat.java @@ -2,6 +2,7 @@ import com.example.domain.model.base.AuditingFields; import com.example.domain.model.valueObject.SeatNumber; +import jakarta.persistence.CascadeType; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -9,6 +10,8 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import java.util.List; import lombok.Getter; @Getter @@ -25,6 +28,9 @@ public class Seat extends AuditingFields { @JoinColumn(nullable = false) private Theater theater; + @OneToMany(mappedBy = "seat", cascade = CascadeType.ALL) + private List screeningSeats; + protected Seat() {} diff --git a/movie-domain/src/main/java/com/example/domain/model/valueObject/SeatNumber.java b/movie-domain/src/main/java/com/example/domain/model/valueObject/SeatNumber.java index b1e4a11d0..de8f62a0a 100644 --- a/movie-domain/src/main/java/com/example/domain/model/valueObject/SeatNumber.java +++ b/movie-domain/src/main/java/com/example/domain/model/valueObject/SeatNumber.java @@ -3,21 +3,24 @@ import static java.util.Objects.hash; import jakarta.persistence.Embeddable; +import java.util.Objects; +import lombok.Getter; +@Getter @Embeddable public class SeatNumber { - private String seatRow; + private Character seatRow; private int seatColumn; protected SeatNumber() {} - private SeatNumber(String seatRow, int seatColumn) { + private SeatNumber(Character seatRow, int seatColumn) { this.seatRow = seatRow; this.seatColumn = seatColumn; } - public static SeatNumber of(String row, int column) { + public static SeatNumber of(Character row, int column) { return new SeatNumber(row, column); } @@ -25,7 +28,7 @@ public static SeatNumber of(String row, int column) { public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof SeatNumber that)) return false; - return seatColumn == that.seatColumn && seatRow.equals(that.seatRow); // 좌석 번호가 같은 경우 동일한 객체로 취급 + return seatColumn == that.seatColumn && Objects.equals(seatRow, that.seatRow); // 좌석 번호가 같은 경우 동일한 객체로 취급 } @Override @@ -35,7 +38,7 @@ public int hashCode() { @Override public String toString() { - return seatRow + seatColumn; + return seatRow.toString() + seatColumn; } } diff --git a/movie-domain/src/main/java/com/example/domain/validation/MovieValidation.java b/movie-domain/src/main/java/com/example/domain/validation/MovieValidation.java index 33a3ce966..3d72440f1 100644 --- a/movie-domain/src/main/java/com/example/domain/validation/MovieValidation.java +++ b/movie-domain/src/main/java/com/example/domain/validation/MovieValidation.java @@ -2,18 +2,23 @@ import com.example.domain.exception.CustomException; import com.example.domain.exception.ErrorCode; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import org.springframework.stereotype.Component; @Component public class MovieValidation { - private static final int MAX_TITLE_LENGTH = 50; + private static final int MAX_TITLE_BYTE_LENGTH = 150; // 스키마 상 varchar(150)과 동일하게 바이트 단위 제한 + private static final Charset CHARSET = StandardCharsets.UTF_8; // 운영 체제마다 기본 인코딩이 다를 수 있으므로 바이트 계산의 기준을 명시 public void validateTitleLength(String title) { if (title == null) { // title 이 null 이면 검증 로직을 실행하지 않음 return; } - if (title.length() > MAX_TITLE_LENGTH) { + + int byteLength = title.getBytes(CHARSET).length; + if (byteLength > MAX_TITLE_BYTE_LENGTH) { throw new CustomException(ErrorCode.INVALID_TITLE_LENGTH); } } diff --git a/movie-domain/src/main/java/com/example/domain/validation/ReservationValidation.java b/movie-domain/src/main/java/com/example/domain/validation/ReservationValidation.java new file mode 100644 index 000000000..4e031b29f --- /dev/null +++ b/movie-domain/src/main/java/com/example/domain/validation/ReservationValidation.java @@ -0,0 +1,67 @@ +package com.example.domain.validation; + +import com.example.domain.exception.CustomException; +import com.example.domain.exception.ErrorCode; +import com.example.domain.model.entity.ScreeningSeat; +import com.example.domain.model.entity.Seat; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import org.springframework.stereotype.Component; + +@Component +public class ReservationValidation { + + private static final int MAX_SEATS_PER_SCREENING = 5; + + public void validateReservationRequest(Long screeningId, Long memberId, List seatIds) { + if (screeningId == null) { + throw new CustomException(ErrorCode.INVALID_REQUEST, "screeningId는 필수 값입니다."); + } + if (memberId == null) { + throw new CustomException(ErrorCode.INVALID_REQUEST, "memberId는 필수 값입니다."); + } + if (seatIds == null || seatIds.isEmpty()) { + throw new CustomException(ErrorCode.INVALID_REQUEST, "seatIds는 필수 값이며 최소 1개 이상의 좌석이 필요합니다."); + } + } + + public void validateMaxSeatsPerScreening(int existingReservations, int newSeatCount) { + if (existingReservations + newSeatCount > MAX_SEATS_PER_SCREENING) { + throw new CustomException(ErrorCode.MAX_SEATS_EXCEEDED); + } + } + + public void validateSeatsAreConsecutive(List seats) { + if (seats == null || seats.isEmpty()) { + throw new CustomException(ErrorCode.INVALID_REQUEST, "요청된 좌석 리스트가 null이거나 비어 있습니다."); + } + + Map> rowGroupedSeats = seats.stream() + .collect(Collectors.groupingBy( + seat -> seat.getSeatNumber().getSeatRow(), // 좌석의 행으로 그룹화 (A, B, C, D, E) + Collectors.mapping(seat -> seat.getSeatNumber().getSeatColumn(), Collectors.toList()) // 좌석의 열을 리스트로 저장 + )); // rowGroupedSeats 예시: { 'A' -> [1, 2], 'B' -> [3] } (이 경우 size()는 2개) + + if (rowGroupedSeats.size() > 1) { + throw new CustomException(ErrorCode.SEATS_NOT_CONSECUTIVE); + } + + List seatNumbers = rowGroupedSeats.values().iterator().next(); // rowGroupedSeats 첫 번째 리스트만 가져옴 + seatNumbers.sort(Integer::compareTo); // 오른차순 정렬 + + for (int i = 0; i < seatNumbers.size() - 1; i++) { + if (seatNumbers.get(i) + 1 != seatNumbers.get(i + 1)) { // 현재 좌석과 다음 좌석의 차이가 1인지 확인 + throw new CustomException(ErrorCode.SEATS_NOT_CONSECUTIVE); + } + } + } + + public void validateSeatsExist(List requestedSeatIds, List foundSeats) { + if (foundSeats.size() != requestedSeatIds.size()) { + throw new CustomException(ErrorCode.INVALID_REQUEST, "요청한 좌석 정보가 유효하지 않습니다."); + } + } + +} diff --git a/movie-infrastructure/build.gradle b/movie-infrastructure/build.gradle index 41d12691b..4210b17e0 100644 --- a/movie-infrastructure/build.gradle +++ b/movie-infrastructure/build.gradle @@ -1,5 +1,5 @@ -bootJar { enabled = false } -jar { enabled = true } +bootJar { enabled = true } +jar { enabled = false } dependencies { implementation project(':movie-application') @@ -22,5 +22,5 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' // LocalDate -> Json 직렬화 지원 + implementation 'org.redisson:redisson-spring-boot-starter:3.22.0' } diff --git a/movie-infrastructure/src/main/java/com/example/infrastructure/db/MemberJpaRepository.java b/movie-infrastructure/src/main/java/com/example/infrastructure/db/MemberJpaRepository.java new file mode 100644 index 000000000..def6b259e --- /dev/null +++ b/movie-infrastructure/src/main/java/com/example/infrastructure/db/MemberJpaRepository.java @@ -0,0 +1,7 @@ +package com.example.infrastructure.db; + +import com.example.domain.model.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberJpaRepository extends JpaRepository { +} diff --git a/movie-infrastructure/src/main/java/com/example/infrastructure/db/ReservationJpaRepository.java b/movie-infrastructure/src/main/java/com/example/infrastructure/db/ReservationJpaRepository.java new file mode 100644 index 000000000..81d6a2e92 --- /dev/null +++ b/movie-infrastructure/src/main/java/com/example/infrastructure/db/ReservationJpaRepository.java @@ -0,0 +1,10 @@ +package com.example.infrastructure.db; + +import com.example.domain.model.entity.Member; +import com.example.domain.model.entity.Reservation; +import com.example.domain.model.entity.Screening; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReservationJpaRepository extends JpaRepository { + int countByScreeningAndMember(Screening screening, Member member); +} diff --git a/movie-infrastructure/src/main/java/com/example/infrastructure/db/ReservedSeatJpaRepository.java b/movie-infrastructure/src/main/java/com/example/infrastructure/db/ReservedSeatJpaRepository.java new file mode 100644 index 000000000..0cb35997a --- /dev/null +++ b/movie-infrastructure/src/main/java/com/example/infrastructure/db/ReservedSeatJpaRepository.java @@ -0,0 +1,7 @@ +package com.example.infrastructure.db; + +import com.example.domain.model.entity.ReservedSeat; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReservedSeatJpaRepository extends JpaRepository { +} diff --git a/movie-infrastructure/src/main/java/com/example/infrastructure/db/ScreeningSeatJpaRepository.java b/movie-infrastructure/src/main/java/com/example/infrastructure/db/ScreeningSeatJpaRepository.java new file mode 100644 index 000000000..992645ba0 --- /dev/null +++ b/movie-infrastructure/src/main/java/com/example/infrastructure/db/ScreeningSeatJpaRepository.java @@ -0,0 +1,21 @@ +package com.example.infrastructure.db; + +import com.example.domain.model.entity.Screening; +import com.example.domain.model.entity.ScreeningSeat; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +public interface ScreeningSeatJpaRepository extends JpaRepository { + List findByScreeningAndSeat_IdIn(Screening screening, List seatIds); + + @Modifying + @Transactional + @Query("UPDATE ScreeningSeat s SET s.reserved = false, s.version = 0") + void resetAllReservations(); + + long countByScreeningIdAndReservedIsTrue(Long screeningId); + +} diff --git a/movie-infrastructure/src/main/java/com/example/infrastructure/db/config/CacheConfig.java b/movie-infrastructure/src/main/java/com/example/infrastructure/db/config/CacheConfig.java index f891bd8fe..b648402fa 100644 --- a/movie-infrastructure/src/main/java/com/example/infrastructure/db/config/CacheConfig.java +++ b/movie-infrastructure/src/main/java/com/example/infrastructure/db/config/CacheConfig.java @@ -18,9 +18,9 @@ public class CacheConfig { /** * Redis 기본 캐시 설정을 정의하는 메서드 * - 모든 Redis 캐시에 적용될 기본 설정을 정의 - * - TTL(캐시 유효 시간): 60초로 설정 + * - TTL(캐시 유효 시간): 영화 정보는 자주 변경되지 않으므로 20분으로 설정 * - NULL 값 캐싱 방지 - * - Key는 문자열(String), Value는 JSON으로 직렬화 + * - Key는 문자열(String), Value는 jdk 직렬화 방식으로 저장된 객체 */ @Bean public RedisCacheConfiguration redisCacheConfiguration() { diff --git a/movie-infrastructure/src/main/java/com/example/infrastructure/db/config/RedissonConfig.java b/movie-infrastructure/src/main/java/com/example/infrastructure/db/config/RedissonConfig.java new file mode 100644 index 000000000..01174150c --- /dev/null +++ b/movie-infrastructure/src/main/java/com/example/infrastructure/db/config/RedissonConfig.java @@ -0,0 +1,27 @@ +package com.example.infrastructure.db.config; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RedissonConfig { + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer() + .setAddress("redis://localhost:6379") + .setConnectionPoolSize(10) + .setConnectionMinimumIdleSize(5) + .setTimeout(5000) // 응답 타임아웃 (명령 실행에 대한 응답) + .setRetryAttempts(3) // 최대 3번 재시도 + .setRetryInterval(1500) // 재시도 간격 1.5초 + .setConnectTimeout(5000); // 연결 타임아웃 (연결 시도에 대한 응답) + + return Redisson.create(config); + } + +} diff --git a/movie-infrastructure/src/main/java/com/example/infrastructure/db/init/StartupRunner.java b/movie-infrastructure/src/main/java/com/example/infrastructure/db/init/StartupRunner.java new file mode 100644 index 000000000..e55b47637 --- /dev/null +++ b/movie-infrastructure/src/main/java/com/example/infrastructure/db/init/StartupRunner.java @@ -0,0 +1,43 @@ +package com.example.infrastructure.db.init; + +import com.example.application.port.out.ScreeningSeatRepositoryPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class StartupRunner { + + private final JdbcTemplate jdbcTemplate; + private final ScreeningSeatRepositoryPort screeningSeatRepositoryPort; + + @EventListener(ApplicationReadyEvent.class) + public void initializeData() { + try { + long screeningSeatCount = screeningSeatRepositoryPort.count(); + if (screeningSeatCount == 0) { // 데이터가 없는 경우만 실행 + generateScreeningSeatData(); + log.info("Screening Seat Data has been created"); + } else { + log.info("Screening Seat Data already exists"); + } + } catch (Exception e) { + log.error("Failed to initialize Screening Seat Data", e); + } + } + + private void generateScreeningSeatData() { + String sql = """ + INSERT INTO screening_seat (screening_id, seat_id, reserved, version) + SELECT s.id, se.id, false, 0 + FROM screening s + CROSS JOIN seat se; + """; + jdbcTemplate.execute(sql); + } +} diff --git a/movie-infrastructure/src/main/java/com/example/infrastructure/db/querydsl/MovieRepositoryCustom.java b/movie-infrastructure/src/main/java/com/example/infrastructure/db/querydsl/MovieRepositoryCustom.java index 22a1f2402..ca1c98b63 100644 --- a/movie-infrastructure/src/main/java/com/example/infrastructure/db/querydsl/MovieRepositoryCustom.java +++ b/movie-infrastructure/src/main/java/com/example/infrastructure/db/querydsl/MovieRepositoryCustom.java @@ -1,10 +1,10 @@ package com.example.infrastructure.db.querydsl; +import com.example.application.dto.request.MovieSearchCriteria; import com.example.domain.model.entity.Movie; -import com.example.domain.model.valueObject.Genre; import java.util.List; import org.springframework.data.domain.Sort; public interface MovieRepositoryCustom { - List findByFilters(String title, Genre genre, Sort sort); + List findByFilters(MovieSearchCriteria movieSearchCriteria, Sort sort); } diff --git a/movie-infrastructure/src/main/java/com/example/infrastructure/db/querydsl/MovieRepositoryCustomImpl.java b/movie-infrastructure/src/main/java/com/example/infrastructure/db/querydsl/MovieRepositoryCustomImpl.java index 0bd2cd49e..df3600d7a 100644 --- a/movie-infrastructure/src/main/java/com/example/infrastructure/db/querydsl/MovieRepositoryCustomImpl.java +++ b/movie-infrastructure/src/main/java/com/example/infrastructure/db/querydsl/MovieRepositoryCustomImpl.java @@ -1,5 +1,6 @@ package com.example.infrastructure.db.querydsl; +import com.example.application.dto.request.MovieSearchCriteria; import com.example.domain.model.entity.Movie; import com.example.domain.model.entity.QMovie; import com.example.domain.model.entity.QScreening; @@ -12,7 +13,9 @@ import com.querydsl.core.types.dsl.PathBuilder; import com.querydsl.core.types.dsl.SimplePath; import com.querydsl.jpa.JPQLQuery; +import java.util.ArrayList; import java.util.List; +import java.util.Set; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport; import org.springframework.stereotype.Repository; @@ -25,11 +28,14 @@ public MovieRepositoryCustomImpl() { } @Override - public List findByFilters(String title, Genre genre, Sort sort) { + public List findByFilters(MovieSearchCriteria movieSearchCriteria, Sort sort) { QMovie movie = QMovie.movie; QScreening screening = QScreening.screening; QTheater theater = QTheater.theater; + String title = movieSearchCriteria.title(); + Genre genre = movieSearchCriteria.genre(); + // 동적 WHERE 조건 처리 BooleanBuilder builder = new BooleanBuilder(); if (title != null && !title.isEmpty()) { @@ -52,8 +58,14 @@ public List findByFilters(String title, Genre genre, Sort sort) { } /** - * QueryDSL 동적 정렬 적용 메서드 + * QueryDSL 동적 정렬 적용 메서드: + * 주어진 정렬 조건인 `Sort`를 기반으 쿼리에 정렬을 적용한다. + * 여러 개의 정렬이 있을 경우 순서를 유지한다. + * `ALLOWED_SORT_FIELDS`에 명시된 필드만 정렬 조건으로 허용한다. */ + + private static final Set ALLOWED_SORT_FIELDS = Set.of("releaseDate", "runtimeMinutes"); // 정렬 가능한 필드를 명시 + private void applySorting(JPQLQuery query, Sort sort, QMovie movie) { if (sort.isUnsorted()) { query.orderBy(movie.releaseDate.asc()); // 기본 정렬 유지 @@ -61,16 +73,22 @@ private void applySorting(JPQLQuery query, Sort sort, QMovie movie) { } PathBuilder entityPath = new PathBuilder<>(Movie.class, "movie"); + List> orderSpecifiers = new ArrayList<>(); // 여러 정렬 조건을 순서를 유지해 저장할 리스트 생성 + for (Sort.Order order : sort) { String property = order.getProperty(); - Order direction = order.isAscending() ? Order.ASC : Order.DESC; - // 정렬 필드를 ComparableExpressionBase로 변환 - SimplePath path = Expressions.path(Comparable.class, entityPath, property); + if (!ALLOWED_SORT_FIELDS.contains(property)) { + continue; // 허용되지 않은 정렬 필드는 무시 + } + + Order direction = order.isAscending() ? Order.ASC : Order.DESC; - OrderSpecifier orderSpecifier = new OrderSpecifier<>(direction, path); - query.orderBy(orderSpecifier); + SimplePath path = Expressions.path(Comparable.class, entityPath, property); // 정렬할 필드 반환 + orderSpecifiers.add(new OrderSpecifier<>(direction, path)); // 정렬 조건을 리스트에 추가 } + query.orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])); + } } diff --git a/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/MemberJpaRepositoryAdapter.java b/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/MemberJpaRepositoryAdapter.java new file mode 100644 index 000000000..a5d7b6915 --- /dev/null +++ b/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/MemberJpaRepositoryAdapter.java @@ -0,0 +1,21 @@ +package com.example.infrastructure.persistence; + +import com.example.application.port.out.MemberRepositoryPort; +import com.example.domain.model.entity.Member; +import com.example.infrastructure.db.MemberJpaRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@RequiredArgsConstructor +@Repository +public class MemberJpaRepositoryAdapter implements MemberRepositoryPort { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public Optional findById(Long id) { + return memberJpaRepository.findById(id); + } + +} diff --git a/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/MovieJpaRepositoryAdapter.java b/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/MovieJpaRepositoryAdapter.java index 1d7b0e5ec..945839c6d 100644 --- a/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/MovieJpaRepositoryAdapter.java +++ b/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/MovieJpaRepositoryAdapter.java @@ -1,8 +1,8 @@ package com.example.infrastructure.persistence; +import com.example.application.dto.request.MovieSearchCriteria; import com.example.application.port.out.MovieRepositoryPort; import com.example.domain.model.entity.Movie; -import com.example.domain.model.valueObject.Genre; import com.example.infrastructure.db.MovieJpaRepository; import com.example.infrastructure.db.querydsl.MovieRepositoryCustomImpl; import java.util.List; @@ -11,13 +11,6 @@ import org.springframework.data.domain.Sort; import org.springframework.stereotype.Repository; -/** - * This adapter serves as a bridge between the application layer - * and the database layer, allowing the application to access movie - * data through the defined MovieRepositoryPort interface while - * encapsulating the JPA-specific implementation details. - */ - @RequiredArgsConstructor @Repository public class MovieJpaRepositoryAdapter implements MovieRepositoryPort { @@ -26,8 +19,8 @@ public class MovieJpaRepositoryAdapter implements MovieRepositoryPort { private final MovieRepositoryCustomImpl movieRepositoryCustom; @Override - public List findBy(String title, Genre genre, Sort sort) { - return movieRepositoryCustom.findByFilters(title, genre, sort); + public List findBy(MovieSearchCriteria movieSearchCriteria, Sort sort) { + return movieRepositoryCustom.findByFilters(movieSearchCriteria, sort); } @Override diff --git a/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/ReservationJpaRepositoryAdapter.java b/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/ReservationJpaRepositoryAdapter.java new file mode 100644 index 000000000..740fd1534 --- /dev/null +++ b/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/ReservationJpaRepositoryAdapter.java @@ -0,0 +1,38 @@ +package com.example.infrastructure.persistence; + +import com.example.application.port.out.ReservationRepositoryPort; +import com.example.domain.model.entity.Member; +import com.example.domain.model.entity.Reservation; +import com.example.domain.model.entity.Screening; +import com.example.infrastructure.db.ReservationJpaRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@RequiredArgsConstructor +@Repository +public class ReservationJpaRepositoryAdapter implements ReservationRepositoryPort { + + private final ReservationJpaRepository reservationJpaRepository; + + @Override + public void save(Reservation reservation) { + reservationJpaRepository.save(reservation); + } + + @Override + public Optional findById(Long id) { + return reservationJpaRepository.findById(id); + } + + @Override + public void deleteAll() { + reservationJpaRepository.deleteAll(); + } + + @Override + public int countByScreeningAndMember(Screening screening, Member member) { + return reservationJpaRepository.countByScreeningAndMember(screening, member); + } + +} diff --git a/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/ReservedSeatJpaRepositoryAdapter.java b/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/ReservedSeatJpaRepositoryAdapter.java new file mode 100644 index 000000000..5cf72bffe --- /dev/null +++ b/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/ReservedSeatJpaRepositoryAdapter.java @@ -0,0 +1,21 @@ +package com.example.infrastructure.persistence; + +import com.example.application.port.out.ReservedSeatRepositoryPort; +import com.example.domain.model.entity.ReservedSeat; +import com.example.infrastructure.db.ReservedSeatJpaRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@RequiredArgsConstructor +@Repository +public class ReservedSeatJpaRepositoryAdapter implements ReservedSeatRepositoryPort { + + private final ReservedSeatJpaRepository reservedSeatJpaRepository; + + @Override + public void saveAll(List reservedSeat) { + reservedSeatJpaRepository.saveAll(reservedSeat); + } + +} diff --git a/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/ScreeningJpaRepositoryAdapter.java b/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/ScreeningJpaRepositoryAdapter.java index 65066b804..0116d6951 100644 --- a/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/ScreeningJpaRepositoryAdapter.java +++ b/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/ScreeningJpaRepositoryAdapter.java @@ -3,16 +3,10 @@ import com.example.application.port.out.ScreeningRepositoryPort; import com.example.domain.model.entity.Screening; import com.example.infrastructure.db.ScreeningJpaRepository; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; -/** - * This adapter serves as a bridge between the application layer - * and the database layer, allowing the application to access movie - * data through the defined MovieRepositoryPort interface while - * encapsulating the JPA-specific implementation details. - */ - @RequiredArgsConstructor @Repository public class ScreeningJpaRepositoryAdapter implements ScreeningRepositoryPort { @@ -24,4 +18,9 @@ public void save(Screening screening) { screeningJpaRepository.save(screening); } + @Override + public Optional findById(Long id) { + return screeningJpaRepository.findById(id); + } + } diff --git a/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/ScreeningSeatJpaRepositoryAdapter.java b/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/ScreeningSeatJpaRepositoryAdapter.java new file mode 100644 index 000000000..9b17afbaa --- /dev/null +++ b/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/ScreeningSeatJpaRepositoryAdapter.java @@ -0,0 +1,44 @@ +package com.example.infrastructure.persistence; + +import com.example.application.port.out.ScreeningSeatRepositoryPort; +import com.example.domain.model.entity.Screening; +import com.example.domain.model.entity.ScreeningSeat; +import com.example.infrastructure.db.ScreeningSeatJpaRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Repository +public class ScreeningSeatJpaRepositoryAdapter implements ScreeningSeatRepositoryPort { + + private final ScreeningSeatJpaRepository screeningSeatJpaRepository; + + @Override + public List findByScreeningAndSeatIds(Screening screening, List seatIds) { + return screeningSeatJpaRepository.findByScreeningAndSeat_IdIn(screening, seatIds); + } + + @Override + public void saveAndFlush(ScreeningSeat screeningSeat) { + screeningSeatJpaRepository.saveAndFlush(screeningSeat); + } + + @Override + public long count() { + return screeningSeatJpaRepository.count(); + } + + @Override + @Transactional + public void resetAllReservations() { + screeningSeatJpaRepository.resetAllReservations(); + } + + @Override + public long countReservedSeats(Long screeningId) { + return screeningSeatJpaRepository.countByScreeningIdAndReservedIsTrue(screeningId); + } + +} diff --git a/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/SeatJpaRepositoryAdapter.java b/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/SeatJpaRepositoryAdapter.java new file mode 100644 index 000000000..b34d12ee9 --- /dev/null +++ b/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/SeatJpaRepositoryAdapter.java @@ -0,0 +1,10 @@ +package com.example.infrastructure.persistence; + +import com.example.application.port.out.SeatRepositoryPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@RequiredArgsConstructor +@Repository +public class SeatJpaRepositoryAdapter implements SeatRepositoryPort { +} diff --git a/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/TheaterJpaRepositoryAdapter.java b/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/TheaterJpaRepositoryAdapter.java index 35cd73fef..5d808447d 100644 --- a/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/TheaterJpaRepositoryAdapter.java +++ b/movie-infrastructure/src/main/java/com/example/infrastructure/persistence/TheaterJpaRepositoryAdapter.java @@ -7,13 +7,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; -/** - * This adapter serves as a bridge between the application layer - * and the database layer, allowing the application to access movie - * data through the defined MovieRepositoryPort interface while - * encapsulating the JPA-specific implementation details. - */ - @RequiredArgsConstructor @Repository public class TheaterJpaRepositoryAdapter implements TheaterRepositoryPort { @@ -25,5 +18,4 @@ public Optional findById(Long id) { return theaterJpaRepository.findById(id); } - } diff --git a/movie-infrastructure/src/main/java/com/example/infrastructure/validation/ReservationValidationAdapter.java b/movie-infrastructure/src/main/java/com/example/infrastructure/validation/ReservationValidationAdapter.java new file mode 100644 index 000000000..4b51b2f8c --- /dev/null +++ b/movie-infrastructure/src/main/java/com/example/infrastructure/validation/ReservationValidationAdapter.java @@ -0,0 +1,32 @@ +package com.example.infrastructure.validation; + +import com.example.application.port.out.ReservationValidationPort; +import com.example.domain.model.entity.ScreeningSeat; +import com.example.domain.model.entity.Seat; +import com.example.domain.validation.ReservationValidation; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class ReservationValidationAdapter implements ReservationValidationPort { + + private final ReservationValidation reservationValidation; + + @Override + public void validateMaxSeatsPerScreening(int existingReservations, int newSeatCount) { + reservationValidation.validateMaxSeatsPerScreening(existingReservations, newSeatCount); + } + + @Override + public void validateSeatsAreConsecutive(List seats) { + reservationValidation.validateSeatsAreConsecutive(seats); + } + + @Override + public void validateSeatsExist(List requestedSeatIds, List foundSeats) { + reservationValidation.validateSeatsExist(requestedSeatIds, foundSeats); + } + +} diff --git a/movie-infrastructure/src/main/java/com/example/infrastructure/web/MovieController.java b/movie-infrastructure/src/main/java/com/example/infrastructure/web/MovieController.java index ac9bb6054..8676d054f 100644 --- a/movie-infrastructure/src/main/java/com/example/infrastructure/web/MovieController.java +++ b/movie-infrastructure/src/main/java/com/example/infrastructure/web/MovieController.java @@ -1,10 +1,10 @@ package com.example.infrastructure.web; import com.example.application.dto.request.MovieRequestDto; +import com.example.application.dto.request.MovieSearchCriteria; import com.example.application.dto.request.ScreeningRequestDto; import com.example.application.dto.response.MovieResponseDto; import com.example.application.port.in.MovieServicePort; -import com.example.domain.model.valueObject.Genre; import com.example.domain.validation.MovieValidation; import jakarta.validation.Valid; import java.util.List; @@ -12,11 +12,11 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor @@ -29,12 +29,11 @@ public class MovieController { @GetMapping public ResponseEntity> getMovies( - @RequestParam(required = false) String title, - @RequestParam(required = false) Genre genre + @ModelAttribute MovieSearchCriteria criteria ) { - movieValidation.validateTitleLength(title); + movieValidation.validateTitleLength(criteria.title()); - List movies = movieServicePort.findMovies(title, genre); + List movies = movieServicePort.findMovies(criteria); return ResponseEntity.ok(movies); } diff --git a/movie-infrastructure/src/main/java/com/example/infrastructure/web/ReservationController.java b/movie-infrastructure/src/main/java/com/example/infrastructure/web/ReservationController.java new file mode 100644 index 000000000..8267945dd --- /dev/null +++ b/movie-infrastructure/src/main/java/com/example/infrastructure/web/ReservationController.java @@ -0,0 +1,30 @@ +package com.example.infrastructure.web; + +import com.example.application.dto.request.ReservationRequestDto; +import com.example.application.dto.response.ReservationResponseDto; +import com.example.application.port.in.ReservationServicePort; +import com.example.domain.validation.ReservationValidation; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/reservations") +public class ReservationController { + + private final ReservationServicePort reservationServicePort; + private final ReservationValidation reservationValidation; + + @PostMapping + public ResponseEntity createReservation(@RequestBody ReservationRequestDto request) { + reservationValidation.validateReservationRequest(request.screeningId(), request.memberId(), request.seatIds()); + + ReservationResponseDto response = reservationServicePort.create(request); + return ResponseEntity.ok(response); + } + +} diff --git a/movie-infrastructure/src/main/resources/application-dev.yml b/movie-infrastructure/src/main/resources/application-dev.yml index 3b457a95c..c114b4996 100644 --- a/movie-infrastructure/src/main/resources/application-dev.yml +++ b/movie-infrastructure/src/main/resources/application-dev.yml @@ -13,9 +13,8 @@ spring: redis: host: localhost port: 6379 - jackson: - serialization: - fail-on-empty-beans: false + cache: + type: redis jpa: hibernate: diff --git a/movie-infrastructure/src/main/resources/data.sql b/movie-infrastructure/src/main/resources/data.sql index dae01c939..c8b1c3671 100644 --- a/movie-infrastructure/src/main/resources/data.sql +++ b/movie-infrastructure/src/main/resources/data.sql @@ -2524,3 +2524,106 @@ INSERT INTO screening (start_time, end_time, movie_id, theater_id, created_at, c INSERT INTO screening (start_time, end_time, movie_id, theater_id, created_at, created_by, modified_at, modified_by) VALUES ('18:00:00', '20:28:00', (SELECT id FROM movie WHERE title = 'Movie_500'), 10, '2025-01-14T20:39:26', 'system', '2025-01-14T20:39:26', 'system'); INSERT INTO screening (start_time, end_time, movie_id, theater_id, created_at, created_by, modified_at, modified_by) VALUES ('13:00:00', '15:28:00', (SELECT id FROM movie WHERE title = 'Movie_500'), 17, '2025-01-14T20:39:26', 'system', '2025-01-14T20:39:26', 'system'); INSERT INTO screening (start_time, end_time, movie_id, theater_id, created_at, created_by, modified_at, modified_by) VALUES ('18:00:00', '20:28:00', (SELECT id FROM movie WHERE title = 'Movie_500'), 19, '2025-01-14T20:39:26', 'system', '2025-01-14T20:39:26', 'system'); + +-- 회원 100명 +INSERT INTO member (id, name, email, created_at, created_by, modified_at, modified_by) VALUES + (1, 'User1', 'user1@example.com', NOW(), 'system', NOW(), 'system'), + (2, 'User2', 'user2@example.com', NOW(), 'system', NOW(), 'system'), + (3, 'User3', 'user3@example.com', NOW(), 'system', NOW(), 'system'), + (4, 'User4', 'user4@example.com', NOW(), 'system', NOW(), 'system'), + (5, 'User5', 'user5@example.com', NOW(), 'system', NOW(), 'system'), + (6, 'User6', 'user6@example.com', NOW(), 'system', NOW(), 'system'), + (7, 'User7', 'user7@example.com', NOW(), 'system', NOW(), 'system'), + (8, 'User8', 'user8@example.com', NOW(), 'system', NOW(), 'system'), + (9, 'User9', 'user9@example.com', NOW(), 'system', NOW(), 'system'), + (10, 'User10', 'user10@example.com', NOW(), 'system', NOW(), 'system'), + (11, 'User11', 'user11@example.com', NOW(), 'system', NOW(), 'system'), + (12, 'User12', 'user12@example.com', NOW(), 'system', NOW(), 'system'), + (13, 'User13', 'user13@example.com', NOW(), 'system', NOW(), 'system'), + (14, 'User14', 'user14@example.com', NOW(), 'system', NOW(), 'system'), + (15, 'User15', 'user15@example.com', NOW(), 'system', NOW(), 'system'), + (16, 'User16', 'user16@example.com', NOW(), 'system', NOW(), 'system'), + (17, 'User17', 'user17@example.com', NOW(), 'system', NOW(), 'system'), + (18, 'User18', 'user18@example.com', NOW(), 'system', NOW(), 'system'), + (19, 'User19', 'user19@example.com', NOW(), 'system', NOW(), 'system'), + (20, 'User20', 'user20@example.com', NOW(), 'system', NOW(), 'system'), + (21, 'User21', 'user21@example.com', NOW(), 'system', NOW(), 'system'), + (22, 'User22', 'user22@example.com', NOW(), 'system', NOW(), 'system'), + (23, 'User23', 'user23@example.com', NOW(), 'system', NOW(), 'system'), + (24, 'User24', 'user24@example.com', NOW(), 'system', NOW(), 'system'), + (25, 'User25', 'user25@example.com', NOW(), 'system', NOW(), 'system'), + (26, 'User26', 'user26@example.com', NOW(), 'system', NOW(), 'system'), + (27, 'User27', 'user27@example.com', NOW(), 'system', NOW(), 'system'), + (28, 'User28', 'user28@example.com', NOW(), 'system', NOW(), 'system'), + (29, 'User29', 'user29@example.com', NOW(), 'system', NOW(), 'system'), + (30, 'User30', 'user30@example.com', NOW(), 'system', NOW(), 'system'), + (31, 'User31', 'user31@example.com', NOW(), 'system', NOW(), 'system'), + (32, 'User32', 'user32@example.com', NOW(), 'system', NOW(), 'system'), + (33, 'User33', 'user33@example.com', NOW(), 'system', NOW(), 'system'), + (34, 'User34', 'user34@example.com', NOW(), 'system', NOW(), 'system'), + (35, 'User35', 'user35@example.com', NOW(), 'system', NOW(), 'system'), + (36, 'User36', 'user36@example.com', NOW(), 'system', NOW(), 'system'), + (37, 'User37', 'user37@example.com', NOW(), 'system', NOW(), 'system'), + (38, 'User38', 'user38@example.com', NOW(), 'system', NOW(), 'system'), + (39, 'User39', 'user39@example.com', NOW(), 'system', NOW(), 'system'), + (40, 'User40', 'user40@example.com', NOW(), 'system', NOW(), 'system'), + (41, 'User41', 'user41@example.com', NOW(), 'system', NOW(), 'system'), + (42, 'User42', 'user42@example.com', NOW(), 'system', NOW(), 'system'), + (43, 'User43', 'user43@example.com', NOW(), 'system', NOW(), 'system'), + (44, 'User44', 'user44@example.com', NOW(), 'system', NOW(), 'system'), + (45, 'User45', 'user45@example.com', NOW(), 'system', NOW(), 'system'), + (46, 'User46', 'user46@example.com', NOW(), 'system', NOW(), 'system'), + (47, 'User47', 'user47@example.com', NOW(), 'system', NOW(), 'system'), + (48, 'User48', 'user48@example.com', NOW(), 'system', NOW(), 'system'), + (49, 'User49', 'user49@example.com', NOW(), 'system', NOW(), 'system'), + (50, 'User50', 'user50@example.com', NOW(), 'system', NOW(), 'system'), + (51, 'User51', 'user51@example.com', NOW(), 'system', NOW(), 'system'), + (52, 'User52', 'user52@example.com', NOW(), 'system', NOW(), 'system'), + (53, 'User53', 'user53@example.com', NOW(), 'system', NOW(), 'system'), + (54, 'User54', 'user54@example.com', NOW(), 'system', NOW(), 'system'), + (55, 'User55', 'user55@example.com', NOW(), 'system', NOW(), 'system'), + (56, 'User56', 'user56@example.com', NOW(), 'system', NOW(), 'system'), + (57, 'User57', 'user57@example.com', NOW(), 'system', NOW(), 'system'), + (58, 'User58', 'user58@example.com', NOW(), 'system', NOW(), 'system'), + (59, 'User59', 'user59@example.com', NOW(), 'system', NOW(), 'system'), + (60, 'User60', 'user60@example.com', NOW(), 'system', NOW(), 'system'), + (61, 'User61', 'user61@example.com', NOW(), 'system', NOW(), 'system'), + (62, 'User62', 'user62@example.com', NOW(), 'system', NOW(), 'system'), + (63, 'User63', 'user63@example.com', NOW(), 'system', NOW(), 'system'), + (64, 'User64', 'user64@example.com', NOW(), 'system', NOW(), 'system'), + (65, 'User65', 'user65@example.com', NOW(), 'system', NOW(), 'system'), + (66, 'User66', 'user66@example.com', NOW(), 'system', NOW(), 'system'), + (67, 'User67', 'user67@example.com', NOW(), 'system', NOW(), 'system'), + (68, 'User68', 'user68@example.com', NOW(), 'system', NOW(), 'system'), + (69, 'User69', 'user69@example.com', NOW(), 'system', NOW(), 'system'), + (70, 'User70', 'user70@example.com', NOW(), 'system', NOW(), 'system'), + (71, 'User71', 'user71@example.com', NOW(), 'system', NOW(), 'system'), + (72, 'User72', 'user72@example.com', NOW(), 'system', NOW(), 'system'), + (73, 'User73', 'user73@example.com', NOW(), 'system', NOW(), 'system'), + (74, 'User74', 'user74@example.com', NOW(), 'system', NOW(), 'system'), + (75, 'User75', 'user75@example.com', NOW(), 'system', NOW(), 'system'), + (76, 'User76', 'user76@example.com', NOW(), 'system', NOW(), 'system'), + (77, 'User77', 'user77@example.com', NOW(), 'system', NOW(), 'system'), + (78, 'User78', 'user78@example.com', NOW(), 'system', NOW(), 'system'), + (79, 'User79', 'user79@example.com', NOW(), 'system', NOW(), 'system'), + (80, 'User80', 'user80@example.com', NOW(), 'system', NOW(), 'system'), + (81, 'User81', 'user81@example.com', NOW(), 'system', NOW(), 'system'), + (82, 'User82', 'user82@example.com', NOW(), 'system', NOW(), 'system'), + (83, 'User83', 'user83@example.com', NOW(), 'system', NOW(), 'system'), + (84, 'User84', 'user84@example.com', NOW(), 'system', NOW(), 'system'), + (85, 'User85', 'user85@example.com', NOW(), 'system', NOW(), 'system'), + (86, 'User86', 'user86@example.com', NOW(), 'system', NOW(), 'system'), + (87, 'User87', 'user87@example.com', NOW(), 'system', NOW(), 'system'), + (88, 'User88', 'user88@example.com', NOW(), 'system', NOW(), 'system'), + (89, 'User89', 'user89@example.com', NOW(), 'system', NOW(), 'system'), + (90, 'User90', 'user90@example.com', NOW(), 'system', NOW(), 'system'), + (91, 'User91', 'user91@example.com', NOW(), 'system', NOW(), 'system'), + (92, 'User92', 'user92@example.com', NOW(), 'system', NOW(), 'system'), + (93, 'User93', 'user93@example.com', NOW(), 'system', NOW(), 'system'), + (94, 'User94', 'user94@example.com', NOW(), 'system', NOW(), 'system'), + (95, 'User95', 'user95@example.com', NOW(), 'system', NOW(), 'system'), + (96, 'User96', 'user96@example.com', NOW(), 'system', NOW(), 'system'), + (97, 'User97', 'user97@example.com', NOW(), 'system', NOW(), 'system'), + (98, 'User98', 'user98@example.com', NOW(), 'system', NOW(), 'system'), + (99, 'User99', 'user99@example.com', NOW(), 'system', NOW(), 'system'), + (100, 'User100', 'user100@example.com', NOW(), 'system', NOW(), 'system'); diff --git a/movie-infrastructure/src/main/resources/v1.0__initial_schema.sql b/movie-infrastructure/src/main/resources/v1.0__initial_schema.sql index 8fbd4bf75..3c72830d8 100644 --- a/movie-infrastructure/src/main/resources/v1.0__initial_schema.sql +++ b/movie-infrastructure/src/main/resources/v1.0__initial_schema.sql @@ -46,3 +46,44 @@ CREATE TABLE seat ( modified_by VARCHAR(50) NOT NULL -- CONSTRAINT FK_seat_theater FOREIGN KEY (theater_id) REFERENCES theater (id) ); + +CREATE TABLE member ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL, + email VARCHAR(100) NOT NULL UNIQUE, + created_at DATETIME(6) NOT NULL, + created_by VARCHAR(50) NOT NULL, + modified_at DATETIME(6) NOT NULL, + modified_by VARCHAR(50) NOT NULL +); + +-- 예약 생성 시 변경이 발생하는 테이블 +CREATE TABLE reservation ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + screening_id INT UNSIGNED NOT NULL, + member_id INT UNSIGNED NOT NULL, + created_at DATETIME(6) NOT NULL, + created_by VARCHAR(50) NOT NULL, + modified_at DATETIME(6) NOT NULL, + modified_by VARCHAR(50) NOT NULL +-- CONSTRAINT FK_reservation_screening FOREIGN KEY (screening_id) REFERENCES screening(id) ON DELETE CASCADE, +-- CONSTRAINT FK_reservation_member FOREIGN KEY (member_id) REFERENCES member(id) ON DELETE CASCADE +); + +CREATE TABLE reserved_seat ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + reservation_id INT UNSIGNED NOT NULL, + screening_seat_id INT UNSIGNED NOT NULL +-- CONSTRAINT FK_reserved_seat_reservation FOREIGN KEY (reservation_id) REFERENCES reservation(id) ON DELETE CASCADE, +-- CONSTRAINT FK_reserved_seat_screening_seat FOREIGN KEY (screening_seat_id) REFERENCES screening_seat(id) ON DELETE CASCADE +); + +CREATE TABLE screening_seat ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + screening_id INT UNSIGNED NOT NULL, + seat_id INT UNSIGNED NOT NULL, + reserved BOOLEAN NOT NULL DEFAULT FALSE, -- 좌석 예약 여부 + version INT UNSIGNED NOT NULL DEFAULT 0 -- 낙관적 락 적용 +-- CONSTRAINT FK_screening_seat_screening FOREIGN KEY (screening_id) REFERENCES screening(id) ON DELETE CASCADE, +-- CONSTRAINT FK_screening_seat_seat FOREIGN KEY (seat_id) REFERENCES seat(id) ON DELETE CASCADE, +); diff --git a/movie-infrastructure/src/test/java/com/example/infrastructure/ReservationConcurrencyTest.java b/movie-infrastructure/src/test/java/com/example/infrastructure/ReservationConcurrencyTest.java new file mode 100644 index 000000000..6eb671574 --- /dev/null +++ b/movie-infrastructure/src/test/java/com/example/infrastructure/ReservationConcurrencyTest.java @@ -0,0 +1,81 @@ +package com.example.infrastructure; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import com.example.application.dto.request.ReservationRequestDto; +import com.example.application.port.in.ReservationServicePort; +import com.example.application.port.out.ReservationRepositoryPort; +import com.example.application.port.out.ScreeningSeatRepositoryPort; +import com.example.domain.exception.CustomException; +import com.example.domain.exception.ErrorCode; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ReservationConcurrencyTest { + + @Autowired + private ReservationServicePort reservationServicePort; + + @Autowired + private ReservationRepositoryPort reservationRepositoryPort; + + @Autowired + private ScreeningSeatRepositoryPort screeningSeatRepositoryPort; + + private final Long screeningId = 1L; + private final List seatIds = List.of(1L, 2L, 3L); + + @BeforeEach + void setUp() { + // 기존 예약 데이터 초기화 + reservationRepositoryPort.deleteAll(); + screeningSeatRepositoryPort.resetAllReservations(); + } + + @DisplayName("여러 스레드에서 동시에 동일한 좌석을 예약 시 중복 예약이 발생하지 않는다.") + @Test + void givenMultipleThreads_whenReservingSeatsConcurrently_thenShouldPreventDuplicateReservations() throws InterruptedException { + // given: 100개의 동시 예약 요청 설정 + int threadCount = 100; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + Runnable reservationTask = () -> { + try { + Long memberId = Thread.currentThread().threadId(); + ReservationRequestDto request = new ReservationRequestDto(screeningId, memberId, seatIds); + reservationServicePort.create(request); + } catch (Exception e) { + System.err.println("❌ 예약 실패: " + e.getMessage()); + } finally { + latch.countDown(); + } + }; + + // when: 모든 스레드가 동시에 좌석 예약 요청을 보냄 + for (int i = 0; i < threadCount; i++) { + executorService.execute(reservationTask); + } + + latch.await(); // 모든 스레드가 완료될 때까지 대기 + executorService.shutdown(); + + // then: 실제로 예약된 좌석 개수 확인 + long reservedSeatsCount = screeningSeatRepositoryPort.countReservedSeats(screeningId); + System.out.println("✅ 최종 예약된 좌석 개수: " + reservedSeatsCount); + + assertThat(reservedSeatsCount) + .as("동시성 이슈 발생으로 좌석이 중복 예약됨!") + .isEqualTo(seatIds.size()); + } + +} diff --git a/test/loadTest_reservation_conflict.js b/test/loadTest_reservation_conflict.js new file mode 100644 index 000000000..d0290f7ec --- /dev/null +++ b/test/loadTest_reservation_conflict.js @@ -0,0 +1,53 @@ +import http from 'k6/http'; +import { check } from 'k6'; + +export let options = { + scenarios: { + burst_load: { + executor: 'constant-vus', // 단기간 높은 동시 요청 발생 + vus: 500, // 500개의 동시 사용자 유지 + duration: '2s', // 2초 동안 모든 요청을 동시에 보냄 + }, + high_arrival_rate: { + executor: 'constant-arrival-rate', // 초당 일정량의 요청 발생 + rate: 200, // 초당 200개의 요청 발생 + timeUnit: '1s', // 매 초마다 + duration: '5s', // 5초 동안 유지 + preAllocatedVUs: 300, // 미리 300개 VU를 할당하여 지연 없이 시작 + }, + }, + thresholds: { + 'http_req_duration': ['p(95)<500'], // 95%의 요청이 500ms 이하 + 'http_req_failed': ['rate<0.01'], // 실패율이 1% 미만 + 'checks': ['rate>0.9'], // 90% 이상의 요청이 기대한 상태 코드 응답을 받아야 함 + }, +}; + +const BASE_URL = 'http://localhost:8080/api/v1/reservations'; + +// **고정된 Screening과 좌석을 사용하여 동시 예약 충돌을 유도** +const FIXED_SCREENING_ID = 1; // 특정 상영 ID 고정 +const FIXED_SEAT_ID = 3; // 특정 좌석 ID 고정 + +export default function () { + const payload = JSON.stringify({ + screeningId: FIXED_SCREENING_ID, // ✅ 같은 screening ID 사용 + memberId: Math.floor(Math.random() * 100) + 1, // 랜덤 회원 ID (1~100) + seatIds: [FIXED_SEAT_ID] // ✅ 같은 좌석 ID로 요청하여 충돌 유도 + }); + + const params = { + headers: { + 'Content-Type': 'application/json', + }, + }; + + const res = http.post(BASE_URL, payload, params); + + // 동시성 제어가 올바르게 동작하면 일부 요청에서 `409 Conflict` 발생해야 함. + check(res, { + 'is status 200 or 201': (r) => r.status === 200 || r.status === 201, // 정상 예약 + 'is status 409 (conflict)': (r) => r.status === 409, // 예약 충돌 발생 (정상적인 동작) + 'response time < 500ms': (r) => r.timings.duration < 500, // 500ms 이하 응답 + }); +} diff --git a/test/reservation.http b/test/reservation.http new file mode 100644 index 000000000..609cf9602 --- /dev/null +++ b/test/reservation.http @@ -0,0 +1,37 @@ +### 좌석 예약 요청 (성공 케이스) +POST http://localhost:8080/api/v1/reservations +Content-Type: application/json + +{ + "screeningId": 1, + "memberId": 1, + "seatIds": [4, 13] +} + +### 좌석 예약 요청 (잘못된 요청 - seatIds 없음) +POST http://localhost:8080/api/v1/reservations +Content-Type: application/json + +{ + "screeningId": 1, + "memberId": 101, + "seatIds": [] +} + +### 좌석 예약 요청 (잘못된 요청 - screeningId 없음) +POST http://localhost:8080/api/v1/reservations +Content-Type: application/json + +{ + "memberId": 101, + "seatIds": [1, 2, 3] +} + +### 좌석 예약 요청 (잘못된 요청 - memberId 없음) +POST http://localhost:8080/api/v1/reservations +Content-Type: application/json + +{ + "screeningId": 1, + "seatIds": [1, 2, 3] +}