diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..493beace Binary files /dev/null and b/.DS_Store differ diff --git a/http/movie.http b/http/movie.http index 3a8865f6..7bae1563 100644 --- a/http/movie.http +++ b/http/movie.http @@ -1,7 +1,27 @@ ### 현재 상영 중인 영화 조회 -GET http://localhost:8080/api/movies/now-playing?theaterId=2&genre=SF +GET http://localhost:8080/api/movies/now-playing Content-Type: application/json -### 장르별 영화 조회 (구현 예정) -GET http://localhost:8080/api/movies/genre?theaterId=1&genre=ACTION +### 특정 상영관 영화 조회 +GET http://localhost:8080/api/movies/now-playing?theaterId=1 Content-Type: application/json + +### 영화 제목 조회 +GET http://localhost:8080/api/movies/now-playing?title=범죄도시 4 +Content-Type: application/json + +### 특정 상영관 + 영화 제목 조회 +GET http://localhost:8080/api/movies/now-playing?theaterId=1&title=범죄도시 4 +Content-Type: application/json + +### 장르별 영화 조회 +GET http://localhost:8080/api/movies/now-playing?genre=ACTION +Content-Type: application/json + +### 상영관 + 제목 조회 +GET http://localhost:8080/api/movies/now-playing?theaterId=1&title=범죄도시 4 +Content-Type: application/json + +### 상영관 + 제목 조회 +GET http://localhost:8080/api/movies/now-playing?theaterId=1&title=범죄도시 4 +Content-Type: application/json \ No newline at end of file diff --git a/k6-scripts/.DS_Store b/k6-scripts/.DS_Store new file mode 100644 index 00000000..05127580 Binary files /dev/null and b/k6-scripts/.DS_Store differ diff --git a/k6-scripts/movie-api-test.js b/k6-scripts/movie-api-test.js index f27dcef0..25f558b5 100644 --- a/k6-scripts/movie-api-test.js +++ b/k6-scripts/movie-api-test.js @@ -1,98 +1,10 @@ import http from 'k6/http'; -import { check, sleep } from 'k6'; -import { SharedArray } from 'k6/data'; - -// 성능 요구사항에 따른 RPS 계산 -// 실제 테스트에서는 N 값을 적절하게 설정 (예: 5000 DAU) -const N = 10; // DAU -const dailyAccessPerUser = 2; -const dailyTotalAccess = N * dailyAccessPerUser; -const avgRPS = dailyTotalAccess / 86400; -const peakRPS = avgRPS * 10; // 피크 시간대 RPS - -// 테스트 구성 +import { sleep } from 'k6'; export const options = { - // 단계별 부하 증가 프로필 - stages: [ - { duration: '1m', target: Math.ceil(N * 0.2) }, // 워밍업: DAU의 20% - { duration: '2m', target: Math.ceil(N * 0.5) }, // 증가: DAU의 50% - { duration: '5m', target: N }, // 피크: 전체 DAU - { duration: '2m', target: Math.ceil(N * 0.5) }, // 감소: DAU의 50% - { duration: '1m', target: 0 }, // 정리: 0명 - ], - - // 성능 임계값 설정 - thresholds: { - 'http_req_duration': ['p(95)<200'], // 95% 요청의 응답 시간이 200ms 이하 - 'http_req_failed': ['rate<0.01'], // 실패율 1% 이하 - }, + vus: 10, + duration: '30s', }; - -// 영화 제목 샘플 데이터 -const movieTitles = new SharedArray('movie titles', function() { - return [ - '범죄도시 4', - '기생수: 더 그레이', - '서복', - '노량: 죽을 때까지', - '듄: 파트 2', - '귀멸의 칼날' - ]; -}); - -// 장르 샘플 데이터 -const genres = new SharedArray('genres', function() { - return [ - 'ACTION', - 'DRAMA', - 'SF', - 'ANIMATION' - ]; -}); - -// 극장 ID 샘플 -const theaterIds = new SharedArray('theater ids', function() { - // 1부터 20까지의 극장 ID 생성 - return Array.from({ length: 20 }, (_, i) => i + 1); -}); - -export default function() { - // 랜덤 극장 선택 - const theaterId = theaterIds[Math.floor(Math.random() * theaterIds.length)]; - - // 시나리오별 비율 설정 (사용자 행동 패턴 시뮬레이션) - const scenario = Math.random(); - - let response; - - // 시나리오 1: 기본 조회 (50%) - if (scenario < 0.5) { - response = http.get(`http://localhost:8080/api/movies/now-playing?theaterId=${theaterId}`); - } - // 시나리오 2: 제목 검색 (20%) - else if (scenario < 0.7) { - const title = movieTitles[Math.floor(Math.random() * movieTitles.length)]; - response = http.get(`http://localhost:8080/api/movies/now-playing?theaterId=${theaterId}&title=${encodeURIComponent(title)}`); - } - // 시나리오 3: 장르 필터링 (20%) - else if (scenario < 0.9) { - const genre = genres[Math.floor(Math.random() * genres.length)]; - response = http.get(`http://localhost:8080/api/movies/now-playing?theaterId=${theaterId}&genre=${genre}`); - } - // 시나리오 4: 제목 + 장르 검색 (10%) - else { - const title = movieTitles[Math.floor(Math.random() * movieTitles.length)]; - const genre = genres[Math.floor(Math.random() * genres.length)]; - response = http.get(`http://localhost:8080/api/movies/now-playing?theaterId=${theaterId}&title=${encodeURIComponent(title)}&genre=${genre}`); - } - - // 응답 검증 - check(response, { - 'status is 200': (r) => r.status === 200, - 'response time < 200ms': (r) => r.timings.duration < 200, - }); - - // 사용자 행동 시뮬레이션을 위한 대기 시간 - // 피크 시간대 요청률을 맞추기 위해 적절히 조정 - sleep(Math.random() * 3 + 1); // 1-4초 사이 무작위 대기 +export default function () { + http.get('http://localhost:8080/api/movies/now-playing'); + sleep(1); } \ No newline at end of file diff --git a/module-api/build.gradle b/module-api/build.gradle index 514ae311..a16f8328 100644 --- a/module-api/build.gradle +++ b/module-api/build.gradle @@ -20,5 +20,12 @@ dependencies { // API 관련 의존성 implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-validation' +} + +// core 모듈의 리소스를 api 모듈로 복사 +processResources { + from(project(':module-core').sourceSets.main.resources) { + include 'application.yml' + rename 'application.yml', 'core-application.yml' + } } \ No newline at end of file diff --git a/module-api/src/main/java/com/hanghae/module/api/controller/MovieController.java b/module-api/src/main/java/com/hanghae/module/api/controller/MovieController.java index 1be771fd..06a47830 100644 --- a/module-api/src/main/java/com/hanghae/module/api/controller/MovieController.java +++ b/module-api/src/main/java/com/hanghae/module/api/controller/MovieController.java @@ -1,15 +1,13 @@ package com.hanghae.module.api.controller; +import com.hanghae.module.api.dto.request.MovieRequest; import com.hanghae.module.api.dto.response.MovieResponse; import com.hanghae.module.common.dto.MovieDTO; -import com.hanghae.module.common.enums.Genre; -import com.hanghae.module.core.service.MovieService; -import jakarta.validation.constraints.Size; +import com.hanghae.module.domain.service.MovieService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.List; @@ -23,12 +21,10 @@ public class MovieController { @GetMapping("/now-playing") public ResponseEntity> getNowPlayingMovies( - @RequestParam(required = true) Long theaterId, - @Size(max = 255, message = "영화 제목은 255자를 초과할 수 없습니다.") - @RequestParam(required = false) String title, - @RequestParam(required = false) Genre genre + MovieRequest request ) { - List movies = movieService.findAllNowPlayingMovies(theaterId, title, genre); + List movies = movieService.findAllNowPlayingMovies(request.getTheaterId(), request.getTitle(), + request.getGenre()); List response = movies.stream() .map(MovieResponse::from) diff --git a/module-api/src/main/java/com/hanghae/module/api/controller/ReservationController.java b/module-api/src/main/java/com/hanghae/module/api/controller/ReservationController.java new file mode 100644 index 00000000..fcfae5ab --- /dev/null +++ b/module-api/src/main/java/com/hanghae/module/api/controller/ReservationController.java @@ -0,0 +1,28 @@ +package com.hanghae.module.api.controller; + +import com.hanghae.module.api.dto.request.ReservationRequest; +import com.hanghae.module.api.dto.response.ReservationResponse; +import com.hanghae.module.core.facade.ReservationFacade; +import com.hanghae.module.domain.model.Reservation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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; + +@RestController +@RequestMapping("/api/reservations") +@RequiredArgsConstructor +public class ReservationController { + + private final ReservationFacade reservationFacade; + + @PostMapping + public ResponseEntity reserve(@Valid @RequestBody ReservationRequest request) { + Reservation reservation = reservationFacade.reserve(request.getUserId(), request.getScreeningId(), request.getSeatNumbers()); + return ResponseEntity.status(HttpStatus.CREATED).body(ReservationResponse.from(reservation)); + } +} diff --git a/module-api/src/main/java/com/hanghae/module/api/dto/request/MovieRequest.java b/module-api/src/main/java/com/hanghae/module/api/dto/request/MovieRequest.java new file mode 100644 index 00000000..40fa6750 --- /dev/null +++ b/module-api/src/main/java/com/hanghae/module/api/dto/request/MovieRequest.java @@ -0,0 +1,19 @@ +package com.hanghae.module.api.dto.request; + +import com.hanghae.module.common.enums.Genre; +import jakarta.validation.constraints.Size; +import lombok.*; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MovieRequest { + private Long theaterId; + + @Size(max = 255, message = "영화 제목은 255자를 초과할 수 없습니다.") + private String title; + + private Genre genre; +} diff --git a/module-api/src/main/java/com/hanghae/module/api/dto/request/ReservationRequest.java b/module-api/src/main/java/com/hanghae/module/api/dto/request/ReservationRequest.java new file mode 100644 index 00000000..2ac10526 --- /dev/null +++ b/module-api/src/main/java/com/hanghae/module/api/dto/request/ReservationRequest.java @@ -0,0 +1,24 @@ +package com.hanghae.module.api.dto.request; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +public class ReservationRequest { + + @NotNull(message = "사용자 ID는 필수 입력값입니다.") + private Long userId; + + @NotNull(message = "상영 ID는 필수 입력값입니다.") + private Long screeningId; + + @NotEmpty(message = "최소 1개 이상의 좌석을 선택해야 합니다.") + @Size(max = 5, message = "최대 5개까지 좌석 예약이 가능합니다.") + private List seatNumbers; +} diff --git a/module-api/src/main/java/com/hanghae/module/api/dto/response/ReservationResponse.java b/module-api/src/main/java/com/hanghae/module/api/dto/response/ReservationResponse.java new file mode 100644 index 00000000..4e34c41c --- /dev/null +++ b/module-api/src/main/java/com/hanghae/module/api/dto/response/ReservationResponse.java @@ -0,0 +1,33 @@ +package com.hanghae.module.api.dto.response; + +import com.hanghae.module.common.enums.ReservationStatus; +import com.hanghae.module.domain.model.Reservation; +import com.hanghae.module.domain.model.ReservationSeat; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@Builder +public class ReservationResponse { + private Long reservationId; + private Long userId; + private Long screeningId; + private List seatNumbers; + private ReservationStatus status; + private LocalDateTime createdAt; + + public static ReservationResponse from(Reservation reservation) { + return ReservationResponse.builder(). + reservationId(reservation.id()). + userId(reservation.user()). + screeningId(reservation.screening()). + seatNumbers(reservation.seatNumbers()). + status(reservation.status()). + createdAt(reservation.createdAt()). + build(); + } +} diff --git a/module-api/src/main/resources/application.yml b/module-api/src/main/resources/application.yml index f6b13ea0..d845365d 100644 --- a/module-api/src/main/resources/application.yml +++ b/module-api/src/main/resources/application.yml @@ -12,7 +12,7 @@ spring: jpa: show-sql: true hibernate: - ddl-auto: update + ddl-auto: create-drop properties: hibernate: format_sql: true # SQL 로깅 시 포맷팅 diff --git a/module-api/src/main/resources/data.sql b/module-api/src/main/resources/data.sql index 041cdd0b..769f4e6e 100644 --- a/module-api/src/main/resources/data.sql +++ b/module-api/src/main/resources/data.sql @@ -1,62 +1,136 @@ --- Movie 데이터 삽입 (6개) -INSERT INTO movie (title, genre, rating, release_date, running_time, thumbnail_url, created_at, modified_at, created_by, - modified_by) -VALUES ('범죄도시 4', 'ACTION', '15세 관람가', '2024-01-20', 130, - 'https://i.namu.wiki/i/Kb880vXiRaiNyh556Q6LGb56NvKM8k6XS2fYAHYFC4VM3DKw77CurLx4HW_we5MZak29SsVM1i6nCC2VUsaqczisTb_s_8GtWuUk6XCiyLK_TGmM1NWrDVJhmtBc_fUF8EVTLNtwhtfruPzlML5fag.webp', - NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('기생수: 더 그레이', 'SF', '15세 관람가', '2024-02-15', 125, - 'https://i.namu.wiki/i/plJt7uVrjVMk-KL_cdNecs97FwfZudbICm7F1YTiAKC9KlMDKd1Bvn2FCNapjMD5kbc5gww2BnC2kDovr4tIxDGXv4yudS3E8EEYXWtde0Dpuu0iypzeRJGi-dMzwUCh0WTItw8eb5PS_08RJgKKVA.webp', - NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('서복', 'DRAMA', '12세 관람가', '2024-03-01', 115, - 'https://i.namu.wiki/i/-KuuA3qdDNht1HuRIoENNXuR8mQtXZEWmssboznjBRvDteJettNkNmf7_hvokqIMGyD2F85DAzEQj-3xnmr0qga5J580J9Alj5CwAiG8rJJ9EsYB9h9HkzIIHZsSkftkCwsvtzqMssKguxqvM76q1Q.webp', - NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - (' 노량: 죽을 때까지', 'DRAMA', '15세 관람가', '2024-01-10', 140, - 'https://i.namu.wiki/i/SodURpRWu-cu6eMPhMwUX_QY9QGd9T-Jyt8RPGjVaXmXBwCoxdBh21YtsWe1zJkI7SSoqGtuEYH1OAVZoEmhaZhyk2LQxof7hs6b0xe5OWZ7tHQUKa4n1BqQuRUieFdHrskXhL5WLUTcDHJkIbBbuw.webp', - NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('듄: 파트 2', 'SF', '12세 관람가', '2024-02-28', 165, - 'https://i.namu.wiki/i/gqfNru-_hEtLLHhXgjiZXQWhJ86nR8gm8Vk6dj5CQEv5uG6VfDTG-XE32M8sqZp8Da9jEDnuTpQ0_9jY0BJC5ue7jG9Fe1gD9t2ToGNxJi89Ffws4UQVA2mkDIYn0LPQs7b2kSfDgjhwq8-jdXJsRA.webp', - NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('귀멸의 칼날', 'ANIMATION', '15세 관람가', '2024-02-10', 110, - 'https://i.namu.wiki/i/kF45TT1axGKhxSSs-5K7aiO-YQ_6_qkms5ZEs_40oqG1d2IzoJFfew3lMtO03HlUWl5egBcGktsGth2CrUubQl9rNo_HKhFaH0BLQy0YqJ4ES_xjID3EnvEsdFZa_hskmaRSmhM_21RdO0voMDYMsQ.webp', - NOW(), NOW(), 'SYSTEM', 'SYSTEM'); +-- 재귀 깊이 제한 증가 +SET @@cte_max_recursion_depth = 10000; --- Theater 데이터 삽입 (20개) +-- 영화 테이블 대량 데이터 생성 (200개 영화) +INSERT INTO movie (title, genre, rating, release_date, running_time, thumbnail_url, created_at, modified_at, created_by, modified_by) +WITH RECURSIVE movie_data(n) AS ( + SELECT 1 + UNION ALL + SELECT n + 1 FROM movie_data WHERE n < 200 +) +SELECT + CASE (n % 20) + WHEN 0 THEN CONCAT('액션 블록버스터 ', n) + WHEN 1 THEN CONCAT('로맨틱 코미디 ', n) + WHEN 2 THEN CONCAT('SF 어드벤처 ', n) + WHEN 3 THEN CONCAT('판타지 대모험 ', n) + WHEN 4 THEN CONCAT('공포 스릴러 ', n) + WHEN 5 THEN CONCAT('가족 애니메이션 ', n) + WHEN 6 THEN CONCAT('역사 드라마 ', n) + WHEN 7 THEN CONCAT('첩보 액션 ', n) + WHEN 8 THEN CONCAT('스포츠 드라마 ', n) + WHEN 9 THEN CONCAT('뮤지컬 영화 ', n) + WHEN 10 THEN CONCAT('서부 대모험 ', n) + WHEN 11 THEN CONCAT('미스터리 스릴러 ', n) + WHEN 12 THEN CONCAT('로봇 전쟁 ', n) + WHEN 13 THEN CONCAT('수퍼히어로 ', n) + WHEN 14 THEN CONCAT('다크 판타지 ', n) + WHEN 15 THEN CONCAT('청춘 성장 영화 ', n) + WHEN 16 THEN CONCAT('범죄 느와르 ', n) + WHEN 17 THEN CONCAT('타임 트래블 ', n) + WHEN 18 THEN CONCAT('좀비 아포칼립스 ', n) + WHEN 19 THEN CONCAT('디스토피아 SF ', n) + END, + CASE (n % 10) + WHEN 0 THEN 'ACTION' + WHEN 1 THEN 'COMEDY' + WHEN 2 THEN 'SF' + WHEN 3 THEN 'FANTASY' + WHEN 4 THEN 'HORROR' + WHEN 5 THEN 'ANIMATION' + WHEN 6 THEN 'DRAMA' + WHEN 7 THEN 'THRILLER' + WHEN 8 THEN 'ADVENTURE' + WHEN 9 THEN 'ROMANCE' + END, + CASE (n % 5) + WHEN 0 THEN '전체 관람가' + WHEN 1 THEN '12세 관람가' + WHEN 2 THEN '15세 관람가' + WHEN 3 THEN '청소년 관람불가' + WHEN 4 THEN '19세 관람가' + END, + -- 최근 2년 내 개봉 영화 분포 (가장 최근 6개월에 더 많은 영화 배치) + CASE + WHEN n % 4 = 0 THEN DATE_SUB(CURRENT_DATE(), INTERVAL (n % 180) DAY) -- 최근 6개월 + WHEN n % 4 = 1 THEN DATE_SUB(CURRENT_DATE(), INTERVAL (180 + (n % 180)) DAY) -- 6개월-1년 + WHEN n % 4 = 2 THEN DATE_SUB(CURRENT_DATE(), INTERVAL (365 + (n % 180)) DAY) -- 1년-1.5년 + ELSE DATE_SUB(CURRENT_DATE(), INTERVAL (545 + (n % 180)) DAY) -- 1.5년-2년 + END, + -- 상영 시간 (80~180분) + 80 + (n % 101), + CONCAT('https://example.com/movie-thumbnails/', n, '.jpg'), + NOW(), NOW(), 'SYSTEM', 'SYSTEM' +FROM movie_data; + +-- 극장 테이블 확장 (100개 극장) INSERT INTO theater (name, created_at, modified_at, created_by, modified_by) -VALUES ('메가박스 강남', NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('CGV 명동', NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('롯데시네마 잠실', NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('메가박스 홍대', NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('CGV 강변', NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('롯데시네마 건대', NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('메가박스 목동', NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('CGV 신촌', NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('롯데시네마 영등포', NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('메가박스 센텀시티', NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('CGV 당산', NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('롯데시네마 청량리', NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('메가박스 상암', NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('CGV 동대문', NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('롯데시네마 노원', NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('메가박스 대학로', NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('CGV 안양', NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('롯데시네마 수원', NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('메가박스 부천', NOW(), NOW(), 'SYSTEM', 'SYSTEM'), - ('CGV 광명', NOW(), NOW(), 'SYSTEM', 'SYSTEM'); +WITH RECURSIVE theater_data(n) AS ( + SELECT 21 -- 기존 극장이 20개까지 있으므로 21부터 시작 + UNION ALL + SELECT n + 1 FROM theater_data WHERE n < 100 +) +SELECT + CASE (n % 3) + WHEN 0 THEN CONCAT('메가박스 ', n, '호점') + WHEN 1 THEN CONCAT('CGV ', n, '호점') + ELSE CONCAT('롯데시네마 ', n, '호점') + END, + NOW(), NOW(), 'SYSTEM', 'SYSTEM' +FROM theater_data; + +-- 현재 상영 스케줄을 대량으로 생성 (10,000개) +-- 최근 개봉 영화에 더 많은 상영 스케줄 할당 +INSERT INTO screening (movie_id, theater_id, start_time, end_time, created_at, modified_at, created_by, modified_by) +WITH RECURSIVE screening_data(n) AS ( + SELECT 1 + UNION ALL + SELECT n + 1 FROM screening_data WHERE n < 10000 +) +SELECT + -- 최근 개봉 영화(낮은 ID)에 더 많은 상영 기회 부여 + CASE + WHEN n % 10 < 6 THEN (n % 50) + 1 -- 60%는 최근 50개 영화 + WHEN n % 10 < 9 THEN (n % 100) + 51 -- 30%는 51-150번 영화 + ELSE (n % 50) + 151 -- 10%는 151-200번 영화 + END, + -- 극장 ID (1-100) + (n % 100) + 1, + -- 상영 시작 시간 (현재부터 2주 이내, 시간대 분포) + CASE + -- 오전(10-12시): 15% + WHEN n % 100 < 15 THEN + DATE_ADD(CURRENT_DATE(), INTERVAL (n % 14) DAY) + INTERVAL (10 + (n % 3)) HOUR + INTERVAL ((n * 13) % 60) MINUTE + -- 낮(12-16시): 20% + WHEN n % 100 < 35 THEN + DATE_ADD(CURRENT_DATE(), INTERVAL (n % 14) DAY) + INTERVAL (12 + (n % 4)) HOUR + INTERVAL ((n * 7) % 60) MINUTE + -- 저녁(16-20시): 35% + WHEN n % 100 < 70 THEN + DATE_ADD(CURRENT_DATE(), INTERVAL (n % 14) DAY) + INTERVAL (16 + (n % 4)) HOUR + INTERVAL ((n * 11) % 60) MINUTE + -- 밤(20-24시): 30% + ELSE + DATE_ADD(CURRENT_DATE(), INTERVAL (n % 14) DAY) + INTERVAL (20 + (n % 4)) HOUR + INTERVAL ((n * 17) % 60) MINUTE +END, + -- 영화 길이에 따른 종료 시간 (영화 길이는 80-180분) + CASE + WHEN n % 100 < 15 THEN + DATE_ADD(CURRENT_DATE(), INTERVAL (n % 14) DAY) + INTERVAL (10 + (n % 3)) HOUR + INTERVAL ((n * 13) % 60) MINUTE + INTERVAL (80 + (n % 101)) MINUTE + WHEN n % 100 < 35 THEN + DATE_ADD(CURRENT_DATE(), INTERVAL (n % 14) DAY) + INTERVAL (12 + (n % 4)) HOUR + INTERVAL ((n * 7) % 60) MINUTE + INTERVAL (80 + (n % 101)) MINUTE + WHEN n % 100 < 70 THEN + DATE_ADD(CURRENT_DATE(), INTERVAL (n % 14) DAY) + INTERVAL (16 + (n % 4)) HOUR + INTERVAL ((n * 11) % 60) MINUTE + INTERVAL (80 + (n % 101)) MINUTE + ELSE + DATE_ADD(CURRENT_DATE(), INTERVAL (n % 14) DAY) + INTERVAL (20 + (n % 4)) HOUR + INTERVAL ((n * 17) % 60) MINUTE + INTERVAL (80 + (n % 101)) MINUTE +END, + NOW(), NOW(), 'SYSTEM', 'SYSTEM' +FROM screening_data; + +-- 인덱스 생성 (기존 인덱스가 있다면 DROP INDEX로 제거하고 진행) +-- 복합 인덱스: 상영 시작 시간 + 영화 ID + 극장 ID +CREATE INDEX idx_screening_start_movie_theater ON screening(start_time, movie_id, theater_id); + +-- 영화 제목에 대한 전문 검색 인덱스 (LIKE '%단어%' 패턴을 위한 최적화) +ALTER TABLE movie ADD FULLTEXT INDEX idx_movie_title_fulltext (title); --- Screening 데이터 대량 삽입 (약 500개) -INSERT INTO screening -(movie_id, theater_id, start_time, end_time, created_at, modified_at, created_by, modified_by) -WITH RECURSIVE screening_data(n) AS (SELECT 1 - UNION ALL - SELECT n + 1 - FROM screening_data - WHERE n < 500) -SELECT 1 + (n % 6), -- movie_id - 1 + (n % 20), -- theater_id - ADDTIME(NOW(), CONCAT(LPAD((n % 24), 2, '0'), ':00:00')), -- start_time - ADDTIME(NOW(), CONCAT(LPAD(((n % 24) + 2), 2, '0'), ':00:00')), -- end_time - NOW(), -- created_at - NOW(), -- modified_at - 'SYSTEM', -- created_by - 'SYSTEM' -- modified_by -FROM screening_data LIMIT 500; \ No newline at end of file +-- 기본 통계 업데이트 (MySQL의 경우) +ANALYZE TABLE movie, theater, screening; \ No newline at end of file diff --git a/module-common/src/main/java/com/hanghae/module/common/enums/Genre.java b/module-common/src/main/java/com/hanghae/module/common/enums/Genre.java index 7f21b04a..2d7dd97d 100644 --- a/module-common/src/main/java/com/hanghae/module/common/enums/Genre.java +++ b/module-common/src/main/java/com/hanghae/module/common/enums/Genre.java @@ -8,7 +8,9 @@ public enum Genre { SF("SF"), ANIMATION("애니메이션"), ROMANCE("로맨스"), - THRILLER("스릴러"); + THRILLER("스릴러"), + FANTASY("판타지"), // 추가 + ADVENTURE("모험"); // 추가 private final String displayName; @@ -19,4 +21,4 @@ public enum Genre { public String getDisplayName() { return displayName; } -} +} \ No newline at end of file diff --git a/module-common/src/main/java/com/hanghae/module/common/enums/ReservationStatus.java b/module-common/src/main/java/com/hanghae/module/common/enums/ReservationStatus.java index 7f6e1e49..c3763907 100644 --- a/module-common/src/main/java/com/hanghae/module/common/enums/ReservationStatus.java +++ b/module-common/src/main/java/com/hanghae/module/common/enums/ReservationStatus.java @@ -4,7 +4,6 @@ public enum ReservationStatus { PENDING("대기중"), CONFIRMED("확정됨"), CANCELED("취소됨"), - COMPLETED("완료됨"), EXPIRED("만료됨"); private final String description; diff --git a/module-common/src/main/java/com/hanghae/module/common/enums/SeatStatus.java b/module-common/src/main/java/com/hanghae/module/common/enums/SeatStatus.java new file mode 100644 index 00000000..b06fcb80 --- /dev/null +++ b/module-common/src/main/java/com/hanghae/module/common/enums/SeatStatus.java @@ -0,0 +1,6 @@ +package com.hanghae.module.common.enums; + +public enum SeatStatus { + AVAILABLE, + RESERVED, +} \ No newline at end of file diff --git a/module-core/build.gradle b/module-core/build.gradle index 6bb588b7..6f1387c8 100644 --- a/module-core/build.gradle +++ b/module-core/build.gradle @@ -5,4 +5,10 @@ plugins { dependencies { implementation project(':module-domain') implementation project(':module-common') + api 'org.springframework.boot:spring-boot-starter-cache' + api 'com.github.ben-manes.caffeine:caffeine' + api 'org.redisson:redisson-spring-boot-starter:3.43.0' + api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + + } \ No newline at end of file diff --git a/module-core/src/main/java/com/hanghae/module/core/config/cache/CacheConfig.java b/module-core/src/main/java/com/hanghae/module/core/config/cache/CacheConfig.java new file mode 100644 index 00000000..b04fffb8 --- /dev/null +++ b/module-core/src/main/java/com/hanghae/module/core/config/cache/CacheConfig.java @@ -0,0 +1,39 @@ +package com.hanghae.module.core.config.cache; + +import com.hanghae.module.common.enums.Genre; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.interceptor.KeyGenerator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.lang.reflect.Method; + +@Configuration +@EnableCaching +public class CacheConfig { + @Bean("movieCacheKeyGenerator") + public KeyGenerator loggingKeyGenerator() { + return new LoggingKeyGenerator(); + } + + public static class LoggingKeyGenerator implements KeyGenerator { + + private static final Logger log = LoggerFactory.getLogger(LoggingKeyGenerator.class); + + @Override + public Object generate(Object target, Method method, Object... params) { + Long theaterId = params[0] != null ? (Long) params[0] : null; + String title = params[1] != null ? (String) params[1] : null; + Genre genre = params[2] != null ? (Genre) params[2] : null; + + String key = (theaterId == null ? "all" : theaterId) + ":" + + (title == null ? "all" : title) + ":" + + (genre == null ? "all" : genre); + + log.info("Generated cache key: {}", key); + return key; + } + } +} diff --git a/module-core/src/main/java/com/hanghae/module/core/config/cache/CaffeineConfig.java b/module-core/src/main/java/com/hanghae/module/core/config/cache/CaffeineConfig.java new file mode 100644 index 00000000..17d567df --- /dev/null +++ b/module-core/src/main/java/com/hanghae/module/core/config/cache/CaffeineConfig.java @@ -0,0 +1,40 @@ +package com.hanghae.module.core.config.cache; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Configuration +@ConditionalOnProperty(name = "spring.cache.type", havingValue = "caffeine", matchIfMissing = true) +public class CaffeineConfig { + private static final Logger log = LoggerFactory.getLogger(CaffeineConfig.class); + + @Value("${spring.cache.ttl:600}") + private int cacheTtl; + + @Value("${spring.cache.max-size:500}") + private int cacheMaxSize; + + @Bean + @Primary + public CacheManager caffeineCacheManager() { + log.info("Caffeine cache manager start"); + CaffeineCacheManager cacheManager = new CaffeineCacheManager(); + cacheManager.setCacheNames(List.of("movies")); + cacheManager.setCaffeine(Caffeine.newBuilder() + .expireAfterWrite(cacheTtl, TimeUnit.SECONDS) + .maximumSize(cacheMaxSize) + .recordStats()); + return cacheManager; + } +} diff --git a/module-core/src/main/java/com/hanghae/module/core/config/cache/RedisConfig.java b/module-core/src/main/java/com/hanghae/module/core/config/cache/RedisConfig.java new file mode 100644 index 00000000..8b93d1ec --- /dev/null +++ b/module-core/src/main/java/com/hanghae/module/core/config/cache/RedisConfig.java @@ -0,0 +1,58 @@ +package com.hanghae.module.core.config.cache; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.codec.TypedJsonJacksonCodec; +import org.redisson.config.Config; +import org.redisson.spring.cache.RedissonSpringCacheManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +@ConditionalOnProperty(name = "spring.cache.type", havingValue = "redis") +public class RedisConfig { + + @Value("${spring.data.redis.host:localhost}") + private String redisHost; + + @Value("${spring.data.redis.port:6379}") + private int redisPort; + + @Value("${spring.cache.ttl:600}") + private int cacheTtl; + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer() + .setAddress("redis://" + redisHost + ":" + redisPort); + + return Redisson.create(config); + } + + @Bean + @Primary + public CacheManager redisCacheManager(RedissonClient redissonClient) { + Map config = new HashMap<>(); + + // TTL 설정 (밀리초 단위로 변환) + int ttlMillis = cacheTtl * 1000; + config.put("movies", new org.redisson.spring.cache.CacheConfig(ttlMillis, ttlMillis / 2)); + + + return new RedissonSpringCacheManager(redissonClient, config); + } +} diff --git a/module-core/src/main/java/com/hanghae/module/core/facade/ReservationFacade.java b/module-core/src/main/java/com/hanghae/module/core/facade/ReservationFacade.java new file mode 100644 index 00000000..9ac01030 --- /dev/null +++ b/module-core/src/main/java/com/hanghae/module/core/facade/ReservationFacade.java @@ -0,0 +1,46 @@ +package com.hanghae.module.core.facade; + +import com.hanghae.module.common.enums.ReservationStatus; +import com.hanghae.module.domain.model.Reservation; +import com.hanghae.module.domain.model.ReservationSeat; +import com.hanghae.module.domain.model.Screening; +import com.hanghae.module.domain.model.Seat; +import com.hanghae.module.domain.service.ReservationSeatService; +import com.hanghae.module.domain.service.ReservationService; +import com.hanghae.module.domain.service.ScreeningService; +import com.hanghae.module.domain.service.SeatService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import java.util.List; + +@Component +@RequiredArgsConstructor +@Transactional +public class ReservationFacade { + + private final ScreeningService screeningService; + private final ReservationSeatService reservationSeatService; + private final ReservationService reservationService; + private final SeatService seatService; + + public Reservation reserve(Long userId, Long screeningId, List seatNumbers) { + // 1. 상영 정보 조회 + Screening screening = screeningService.findById(screeningId); + // 2. 좌석 조회 + List seats = seatService.getSeats(screeningId, seatNumbers); + // 2. 좌석 검증 + seatService.validateSeats(seats, screening); + // 3. 예약 생성 및 저장 + Reservation reservation = reservationService.saveReservation(Reservation.create(userId, screeningId, seatNumbers)); + // 4. 좌석 예약 + seatService.assignSeats(seats); + // 5. 예약 좌석 정보 저장 + for (Seat seat : seats) { + reservationSeatService.saveReservationSeat(ReservationSeat.create(reservation.id(), seat.id())); + } + + //5. 예약 상태 업데이트 + return reservationService.saveReservation(reservation.updateStatus(ReservationStatus.CONFIRMED)); + } +} diff --git a/module-core/src/main/resources/application.yml b/module-core/src/main/resources/application.yml new file mode 100644 index 00000000..8d0cd48b --- /dev/null +++ b/module-core/src/main/resources/application.yml @@ -0,0 +1,11 @@ +spring: + cache: + type: redis # 'caffeine' 또는 'redis' + ttl: 600 # 캐시 만료 시간(초) + max-size: 500 # 최대 캐시 크기 + + data: + redis: + host: localhost + port: 6379 + diff --git a/module-domain/src/main/java/com/hanghae/module/domain/model/Reservation.java b/module-domain/src/main/java/com/hanghae/module/domain/model/Reservation.java index 5dbdbfc2..4cc292b7 100644 --- a/module-domain/src/main/java/com/hanghae/module/domain/model/Reservation.java +++ b/module-domain/src/main/java/com/hanghae/module/domain/model/Reservation.java @@ -4,13 +4,44 @@ import lombok.Builder; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Builder public record Reservation( Long id, Long user, Long screening, + List seatNumbers, ReservationStatus status, LocalDateTime createdAt ) { + + /** + * 새로운 예약을 생성하는 정적 팩토리 메소드 + * + * @param userId 사용자 ID + * @param screeningId 상영 ID + * @return 생성된 예약 객체 + */ + public static Reservation create(Long userId, Long screeningId, List seatNumbers) { + return Reservation.builder() + .user(userId) + .screening(screeningId) + .seatNumbers(seatNumbers) + .status(ReservationStatus.PENDING) + .createdAt(LocalDateTime.now()) + .build(); + } + + public Reservation updateStatus(ReservationStatus status) { + return Reservation.builder() + .id(this.id) + .user(this.user) + .screening(this.screening) + .seatNumbers(this.seatNumbers) + .status(status) + .createdAt(this.createdAt) + .build(); + } } diff --git a/module-domain/src/main/java/com/hanghae/module/domain/model/ReservationSeat.java b/module-domain/src/main/java/com/hanghae/module/domain/model/ReservationSeat.java index 81d005de..b61c4a60 100644 --- a/module-domain/src/main/java/com/hanghae/module/domain/model/ReservationSeat.java +++ b/module-domain/src/main/java/com/hanghae/module/domain/model/ReservationSeat.java @@ -1,8 +1,6 @@ package com.hanghae.module.domain.model; import lombok.Builder; - -import java.math.BigDecimal; import java.time.LocalDateTime; @Builder @@ -10,7 +8,14 @@ public record ReservationSeat( Long id, Long reservation, Long seat, - BigDecimal amount, LocalDateTime reservedAt ) { + + public static ReservationSeat create(Long reservationId, Long seatId) { + return ReservationSeat.builder() + .reservation(reservationId) + .seat(seatId) + .reservedAt(LocalDateTime.now()) + .build(); + } } diff --git a/module-domain/src/main/java/com/hanghae/module/domain/model/Seat.java b/module-domain/src/main/java/com/hanghae/module/domain/model/Seat.java index dc343c13..bd4334ae 100644 --- a/module-domain/src/main/java/com/hanghae/module/domain/model/Seat.java +++ b/module-domain/src/main/java/com/hanghae/module/domain/model/Seat.java @@ -1,7 +1,7 @@ package com.hanghae.module.domain.model; +import com.hanghae.module.common.enums.SeatStatus; import lombok.Builder; - import java.math.BigDecimal; @Builder @@ -9,6 +9,20 @@ public record Seat( Long id, Long screening, String seatNumber, - BigDecimal amount + SeatStatus status, + BigDecimal price ) { + public boolean isReserved() { + if (status == null) { + return false; + } + return status == SeatStatus.RESERVED; + } + + public Seat assign() { + if (isReserved()) { + throw new IllegalStateException("Seat is already reserved"); + } + return new Seat(id, screening, seatNumber, SeatStatus.RESERVED, price); + } } diff --git a/module-domain/src/main/java/com/hanghae/module/domain/repository/ReservationRepository.java b/module-domain/src/main/java/com/hanghae/module/domain/repository/ReservationRepository.java new file mode 100644 index 00000000..830342ed --- /dev/null +++ b/module-domain/src/main/java/com/hanghae/module/domain/repository/ReservationRepository.java @@ -0,0 +1,7 @@ +package com.hanghae.module.domain.repository; + +import com.hanghae.module.domain.model.Reservation; + +public interface ReservationRepository { + Reservation save(Reservation reservation); +} diff --git a/module-domain/src/main/java/com/hanghae/module/domain/repository/ReservationSeatRepository.java b/module-domain/src/main/java/com/hanghae/module/domain/repository/ReservationSeatRepository.java new file mode 100644 index 00000000..02a2adf9 --- /dev/null +++ b/module-domain/src/main/java/com/hanghae/module/domain/repository/ReservationSeatRepository.java @@ -0,0 +1,7 @@ +package com.hanghae.module.domain.repository; + +import com.hanghae.module.domain.model.ReservationSeat; + +public interface ReservationSeatRepository { + void save(ReservationSeat reservationSeat); +} diff --git a/module-domain/src/main/java/com/hanghae/module/domain/repository/ScreeningRepository.java b/module-domain/src/main/java/com/hanghae/module/domain/repository/ScreeningRepository.java new file mode 100644 index 00000000..54d08428 --- /dev/null +++ b/module-domain/src/main/java/com/hanghae/module/domain/repository/ScreeningRepository.java @@ -0,0 +1,7 @@ +package com.hanghae.module.domain.repository; + +import com.hanghae.module.domain.model.Screening; + +public interface ScreeningRepository { + Screening findById(Long id); +} diff --git a/module-domain/src/main/java/com/hanghae/module/domain/repository/SeatRepository.java b/module-domain/src/main/java/com/hanghae/module/domain/repository/SeatRepository.java new file mode 100644 index 00000000..a1640746 --- /dev/null +++ b/module-domain/src/main/java/com/hanghae/module/domain/repository/SeatRepository.java @@ -0,0 +1,11 @@ +package com.hanghae.module.domain.repository; + +import com.hanghae.module.domain.model.Seat; + +import java.util.List; + +public interface SeatRepository { + List getSeats(Long screening, List seatNumbers); + + void saveAll(List seats); +} diff --git a/module-core/src/main/java/com/hanghae/module/core/service/MovieService.java b/module-domain/src/main/java/com/hanghae/module/domain/service/MovieService.java similarity index 84% rename from module-core/src/main/java/com/hanghae/module/core/service/MovieService.java rename to module-domain/src/main/java/com/hanghae/module/domain/service/MovieService.java index 215254e1..20324bfc 100644 --- a/module-core/src/main/java/com/hanghae/module/core/service/MovieService.java +++ b/module-domain/src/main/java/com/hanghae/module/domain/service/MovieService.java @@ -1,4 +1,4 @@ -package com.hanghae.module.core.service; +package com.hanghae.module.domain.service; import com.hanghae.module.common.dto.MovieDTO; diff --git a/module-domain/src/main/java/com/hanghae/module/domain/service/ReservationSeatService.java b/module-domain/src/main/java/com/hanghae/module/domain/service/ReservationSeatService.java new file mode 100644 index 00000000..1fe26a8e --- /dev/null +++ b/module-domain/src/main/java/com/hanghae/module/domain/service/ReservationSeatService.java @@ -0,0 +1,7 @@ +package com.hanghae.module.domain.service; + +import com.hanghae.module.domain.model.ReservationSeat; + +public interface ReservationSeatService { + void saveReservationSeat(ReservationSeat seat); +} diff --git a/module-domain/src/main/java/com/hanghae/module/domain/service/ReservationService.java b/module-domain/src/main/java/com/hanghae/module/domain/service/ReservationService.java new file mode 100644 index 00000000..127d4192 --- /dev/null +++ b/module-domain/src/main/java/com/hanghae/module/domain/service/ReservationService.java @@ -0,0 +1,9 @@ +package com.hanghae.module.domain.service; + +import com.hanghae.module.domain.model.Reservation; + +public interface ReservationService { + + Reservation saveReservation(Reservation reservation); +// Reservation reserve(Long userId, Long screeningId, List seatNumbers); +} diff --git a/module-domain/src/main/java/com/hanghae/module/domain/service/ScreeningService.java b/module-domain/src/main/java/com/hanghae/module/domain/service/ScreeningService.java new file mode 100644 index 00000000..e2f716ac --- /dev/null +++ b/module-domain/src/main/java/com/hanghae/module/domain/service/ScreeningService.java @@ -0,0 +1,8 @@ +package com.hanghae.module.domain.service; + +import com.hanghae.module.domain.model.Screening; + +public interface ScreeningService { + + Screening findById(Long screeningId); +} diff --git a/module-domain/src/main/java/com/hanghae/module/domain/service/SeatService.java b/module-domain/src/main/java/com/hanghae/module/domain/service/SeatService.java new file mode 100644 index 00000000..e7e2b4a6 --- /dev/null +++ b/module-domain/src/main/java/com/hanghae/module/domain/service/SeatService.java @@ -0,0 +1,22 @@ +package com.hanghae.module.domain.service; + +import com.hanghae.module.domain.model.Screening; +import com.hanghae.module.domain.model.Seat; + +import java.util.List; + +public interface SeatService { + + List getSeats(Long screeningId, List seatNumbers); + + /** + * 예약된 좌석 번호를 검증합니다. + * + * @param seats 예약된 좌석 번호 리스트 + * @param screening 상영 정보 + * @throws IllegalArgumentException 좌석 번호가 유효하지 않은 경우 + */ + void validateSeats(List seats, Screening screening); + + void assignSeats(List seats); +} diff --git a/module-core/src/main/java/com/hanghae/module/core/service/impl/MovieServiceImpl.java b/module-domain/src/main/java/com/hanghae/module/domain/service/impl/MovieServiceImpl.java similarity index 75% rename from module-core/src/main/java/com/hanghae/module/core/service/impl/MovieServiceImpl.java rename to module-domain/src/main/java/com/hanghae/module/domain/service/impl/MovieServiceImpl.java index 2f04fd04..95926cdf 100644 --- a/module-core/src/main/java/com/hanghae/module/core/service/impl/MovieServiceImpl.java +++ b/module-domain/src/main/java/com/hanghae/module/domain/service/impl/MovieServiceImpl.java @@ -1,10 +1,11 @@ -package com.hanghae.module.core.service.impl; +package com.hanghae.module.domain.service.impl; import com.hanghae.module.common.dto.MovieDTO; import com.hanghae.module.common.enums.Genre; -import com.hanghae.module.core.service.MovieService; +import com.hanghae.module.domain.service.MovieService; import com.hanghae.module.domain.repository.MovieRepository; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +18,9 @@ public class MovieServiceImpl implements MovieService { private final MovieRepository movieRepository; + @Cacheable(value = "movies", + keyGenerator = "movieCacheKeyGenerator" + ) @Override public List findAllNowPlayingMovies(Long theaterId, String title, Genre genre) { diff --git a/module-domain/src/main/java/com/hanghae/module/domain/service/impl/ReservationSeatServiceImpl.java b/module-domain/src/main/java/com/hanghae/module/domain/service/impl/ReservationSeatServiceImpl.java new file mode 100644 index 00000000..95db0739 --- /dev/null +++ b/module-domain/src/main/java/com/hanghae/module/domain/service/impl/ReservationSeatServiceImpl.java @@ -0,0 +1,23 @@ +package com.hanghae.module.domain.service.impl; + +import com.hanghae.module.domain.model.ReservationSeat; +import com.hanghae.module.domain.model.Screening; +import com.hanghae.module.domain.repository.ReservationSeatRepository; +import com.hanghae.module.domain.service.ReservationSeatService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class ReservationSeatServiceImpl implements ReservationSeatService { + private final ReservationSeatRepository reservationSeatRepository; + + @Override + public void saveReservationSeat(ReservationSeat reservationSeat) { + reservationSeatRepository.save(reservationSeat); + } +} diff --git a/module-domain/src/main/java/com/hanghae/module/domain/service/impl/ReservationServiceImpl.java b/module-domain/src/main/java/com/hanghae/module/domain/service/impl/ReservationServiceImpl.java new file mode 100644 index 00000000..f4450326 --- /dev/null +++ b/module-domain/src/main/java/com/hanghae/module/domain/service/impl/ReservationServiceImpl.java @@ -0,0 +1,20 @@ +package com.hanghae.module.domain.service.impl; + +import com.hanghae.module.domain.repository.ReservationRepository; +import com.hanghae.module.domain.service.ReservationService; +import com.hanghae.module.domain.model.Reservation; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class ReservationServiceImpl implements ReservationService { + private final ReservationRepository reservationRepository; + + @Override + public Reservation saveReservation(Reservation reservation) { + return reservationRepository.save(reservation); + } +} diff --git a/module-domain/src/main/java/com/hanghae/module/domain/service/impl/ScreeningServiceImpl.java b/module-domain/src/main/java/com/hanghae/module/domain/service/impl/ScreeningServiceImpl.java new file mode 100644 index 00000000..2d52f1e7 --- /dev/null +++ b/module-domain/src/main/java/com/hanghae/module/domain/service/impl/ScreeningServiceImpl.java @@ -0,0 +1,22 @@ +package com.hanghae.module.domain.service.impl; + +import com.hanghae.module.domain.model.Screening; +import com.hanghae.module.domain.repository.ScreeningRepository; +import com.hanghae.module.domain.service.ScreeningService; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ScreeningServiceImpl implements ScreeningService { + + private final ScreeningRepository screeningRepository; + + @Override + public Screening findById(Long screeningId) { + return screeningRepository.findById(screeningId); + } +} diff --git a/module-domain/src/main/java/com/hanghae/module/domain/service/impl/SeatServiceImpl.java b/module-domain/src/main/java/com/hanghae/module/domain/service/impl/SeatServiceImpl.java new file mode 100644 index 00000000..53ef24eb --- /dev/null +++ b/module-domain/src/main/java/com/hanghae/module/domain/service/impl/SeatServiceImpl.java @@ -0,0 +1,92 @@ +package com.hanghae.module.domain.service.impl; + +import com.hanghae.module.domain.model.Screening; +import com.hanghae.module.domain.model.Seat; +import com.hanghae.module.domain.repository.SeatRepository; +import com.hanghae.module.domain.service.SeatService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SeatServiceImpl implements SeatService { + private final SeatRepository seatRepository; + + @Override + public void validateSeats(List seats, Screening screening) { + // 1. 좌석 수 검증 (최대 5개) + if (seats.size() > 5) { + throw new IllegalArgumentException("최대 5개까지 좌석 예약이 가능합니다."); + } + + // 2. 좌석 형식 검증 (A1, B3 등) + for (Seat seat : seats) { + String seatNumber = seat.seatNumber(); + if (!isValidSeatFormat(seatNumber)) { + throw new IllegalArgumentException("잘못된 좌석 형식입니다: " + seatNumber); + } + } + + // 3. 인접 좌석 검증 (같은 열에 있고 연속된 번호인지) + validateAdjacentSeats(seats); + + // 4. 예약 가능 여부 검증 + for (Seat seat : seats) { + if (seat.isReserved()) { + throw new IllegalArgumentException("이미 예약된 좌석입니다: " + seat.seatNumber()); + } + } + } + + @Override + public void assignSeats(List seats) { + // 좌석 예약 처리 + List updatedSeats = seats.stream() + .map(Seat::assign) + .toList(); + + seatRepository.saveAll(updatedSeats); + } + + @Override + public List getSeats(Long screeningId, List seatNumbers) { + return seatRepository.getSeats(screeningId, seatNumbers); + } + + private boolean isValidSeatFormat(String seatNumber) { + // 좌석 형식 검증 (A1, B3 등) + return seatNumber.matches("[A-E][1-5]"); + } + + private void validateAdjacentSeats(List seats) { + if (seats.isEmpty()) { + return; + } + + // 모든 좌석이 같은 열에 있는지 확인 + char row = seats.getFirst().seatNumber().charAt(0); + for (Seat seat : seats) { + String seatNumber = seat.seatNumber(); + if (seatNumber.charAt(0) != row) { + throw new IllegalArgumentException("모든 좌석은 같은 열에 있어야 합니다."); + } + } + + // 좌석 번호 추출하고 정렬 + List columns = seats.stream() + .map(seat -> Integer.parseInt(seat.seatNumber().substring(1))) + .sorted() + .toList(); + + // 연속된 번호인지 확인 + for (int i = 0; i < columns.size() - 1; i++) { + if (columns.get(i + 1) - columns.get(i) != 1) { + throw new IllegalArgumentException("좌석은 연속되어야 합니다."); + } + } + } +} diff --git a/module-persistence/build.gradle b/module-persistence/build.gradle index bcce5032..e50e4c28 100644 --- a/module-persistence/build.gradle +++ b/module-persistence/build.gradle @@ -9,4 +9,10 @@ dependencies { // JPA 및 데이터베이스 접근 의존성 implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'com.mysql:mysql-connector-j' + // MapStruct + implementation 'org.mapstruct:mapstruct:1.5.5.Final' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final' + + // Lombok과 MapStruct 함께 사용 시 필요 + annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0' } \ No newline at end of file diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/entity/MovieEntity.java b/module-persistence/src/main/java/com/hanghae/module/persistence/entity/MovieEntity.java index 10f653bd..c9f51da4 100644 --- a/module-persistence/src/main/java/com/hanghae/module/persistence/entity/MovieEntity.java +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/entity/MovieEntity.java @@ -9,12 +9,8 @@ import java.time.LocalDate; @Entity -@Table(name = "movie", - indexes = { - @Index(name = "idx_movie_title", columnList = "title"), - @Index(name = "idx_movie_genre", columnList = "genre"), - @Index(name = "idx_movie_release_date", columnList = "releaseDate DESC") - }) +@Table(name = "movie" + ) @Builder @Getter @NoArgsConstructor @@ -42,39 +38,4 @@ public class MovieEntity extends BaseEntity { @Enumerated(EnumType.STRING) @Column(nullable = false) private Genre genre; - - /** - * 영화 엔티티를 도메인 모델로 변환 - */ - public Movie toDomain() { - - return Movie.builder() - .id(this.id) - .title(this.title) - .rating(this.rating) - .releaseDate(this.releaseDate) - .thumbnailUrl(this.thumbnailUrl) - .runningTime(this.runningTime) - .genre(this.genre) - .build(); - } - - /** - * 영화 도메인 모델을 엔티티로 변환 - */ - public static MovieEntity from(Movie domain) { - if (domain == null) { - return null; - } - - return MovieEntity.builder() - .id(domain.id()) - .title(domain.title()) - .rating(domain.rating()) - .releaseDate(domain.releaseDate()) - .thumbnailUrl(domain.thumbnailUrl()) - .runningTime(domain.runningTime()) - .genre(domain.genre()) - .build(); - } } diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/entity/ReservationEntity.java b/module-persistence/src/main/java/com/hanghae/module/persistence/entity/ReservationEntity.java index b5ff54e3..6189452e 100644 --- a/module-persistence/src/main/java/com/hanghae/module/persistence/entity/ReservationEntity.java +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/entity/ReservationEntity.java @@ -2,7 +2,6 @@ import com.hanghae.module.common.audit.BaseEntity; import com.hanghae.module.common.enums.ReservationStatus; -import com.hanghae.module.domain.model.Reservation; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; @@ -29,33 +28,4 @@ public class ReservationEntity extends BaseEntity { @Enumerated(EnumType.STRING) @Column(nullable = false) private ReservationStatus status; - - /** - * 예약 엔티티를 도메인 모델로 변환 - */ - public Reservation toDomain() { - return Reservation.builder() - .id(this.id) - .user(this.user) - .screening(this.screening) - .status(this.status) - .createdAt(this.getCreatedAt()) - .build(); - } - - /** - * 예약 도메인 모델을 엔티티로 변환 - */ - public static ReservationEntity from(Reservation domain) { - if (domain == null) { - return null; - } - - return ReservationEntity.builder() - .id(domain.id()) - .user(domain.user()) - .screening(domain.screening()) - .status(domain.status()) - .build(); - } } diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/entity/ReservationSeatEntity.java b/module-persistence/src/main/java/com/hanghae/module/persistence/entity/ReservationSeatEntity.java index f638ac1d..ae0dac61 100644 --- a/module-persistence/src/main/java/com/hanghae/module/persistence/entity/ReservationSeatEntity.java +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/entity/ReservationSeatEntity.java @@ -26,30 +26,4 @@ public class ReservationSeatEntity extends BaseEntity { @Column(name = "seat_id", nullable = false) private Long seat; - - @Column(nullable = false) - private BigDecimal amount; - - public ReservationSeat toDomain() { - return ReservationSeat.builder() - .id(this.id) - .reservation(this.reservation) - .seat(this.seat) - .amount(this.amount) - .reservedAt(this.getCreatedAt()) - .build(); - } - - public static ReservationSeatEntity from(ReservationSeat domain) { - if (domain == null) { - return null; - } - - return ReservationSeatEntity.builder() - .id(domain.id()) - .reservation(domain.reservation()) - .seat(domain.seat()) - .amount(domain.amount()) - .build(); - } } diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/entity/ScreeningEntity.java b/module-persistence/src/main/java/com/hanghae/module/persistence/entity/ScreeningEntity.java index 81816dd1..8f1f3f10 100644 --- a/module-persistence/src/main/java/com/hanghae/module/persistence/entity/ScreeningEntity.java +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/entity/ScreeningEntity.java @@ -8,10 +8,8 @@ import java.time.LocalDateTime; @Entity -@Table(name = "screening", - indexes = { - @Index(name = "idx_screening_theater", columnList = "theater_id") - }) +@Table(name = "screening" + ) @Builder @Getter @NoArgsConstructor @@ -33,28 +31,4 @@ public class ScreeningEntity extends BaseEntity { @Column(nullable = false) private LocalDateTime endTime; - - public Screening toDomain() { - return Screening.builder() - .id(this.id) - .movie(this.movie) - .theater(this.theater) - .startTime(this.startTime) - .endTime(this.endTime) - .build(); - } - - public static ScreeningEntity from(Screening domain) { - if (domain == null) { - return null; - } - - return ScreeningEntity.builder() - .id(domain.id()) - .movie(domain.movie()) - .theater(domain.theater()) - .startTime(domain.startTime()) - .endTime(domain.endTime()) - .build(); - } } diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/entity/SeatEntity.java b/module-persistence/src/main/java/com/hanghae/module/persistence/entity/SeatEntity.java index eac325e6..3544cd00 100644 --- a/module-persistence/src/main/java/com/hanghae/module/persistence/entity/SeatEntity.java +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/entity/SeatEntity.java @@ -1,10 +1,12 @@ package com.hanghae.module.persistence.entity; import com.hanghae.module.common.audit.BaseEntity; -import com.hanghae.module.domain.model.Seat; +import com.hanghae.module.common.enums.SeatStatus; import jakarta.persistence.*; -import lombok.*; - +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; import java.math.BigDecimal; @Entity @@ -14,38 +16,21 @@ @NoArgsConstructor @AllArgsConstructor public class SeatEntity extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "screening_id", nullable = false) - private Long screening; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(nullable = false) - private String seatNumber; + @Column(name = "screening_id", nullable = false) + private Long screening; - @Column(nullable = false) - private BigDecimal amount; + @Column(nullable = false) + private String seatNumber; - public Seat toDomain() { - return Seat.builder() - .id(this.id) - .screening(this.screening) - .seatNumber(this.seatNumber) - .amount(this.amount) - .build(); - } + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SeatStatus status; - public static SeatEntity from(Seat domain) { - if (domain == null) { - return null; - } - - return SeatEntity.builder() - .id(domain.id()) - .screening(domain.screening()) - .seatNumber(domain.seatNumber()) - .amount(domain.amount()) - .build(); - } + @Column(nullable = false) + private BigDecimal price; } + diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/entity/TheaterEntity.java b/module-persistence/src/main/java/com/hanghae/module/persistence/entity/TheaterEntity.java index eb47521c..d5e26a3c 100644 --- a/module-persistence/src/main/java/com/hanghae/module/persistence/entity/TheaterEntity.java +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/entity/TheaterEntity.java @@ -1,7 +1,6 @@ package com.hanghae.module.persistence.entity; import com.hanghae.module.common.audit.BaseEntity; -import com.hanghae.module.domain.model.Theater; import jakarta.persistence.*; import lombok.*; @@ -19,22 +18,4 @@ public class TheaterEntity extends BaseEntity { @Column(nullable = false) private String name; - - public Theater toDomain() { - return Theater.builder() - .id(this.id) - .name(this.name) - .build(); - } - - public static TheaterEntity from(Theater domain) { - if (domain == null) { - return null; - } - - return TheaterEntity.builder() - .id(domain.id()) - .name(domain.name()) - .build(); - } } diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/entity/UserEntity.java b/module-persistence/src/main/java/com/hanghae/module/persistence/entity/UserEntity.java index 4fe5e31a..3a314ef8 100644 --- a/module-persistence/src/main/java/com/hanghae/module/persistence/entity/UserEntity.java +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/entity/UserEntity.java @@ -20,22 +20,4 @@ public class UserEntity { @Column(nullable = false) private String name; - - public User toDomain() { - return User.builder() - .id(this.id) - .name(this.name) - .build(); - } - - public static UserEntity from(User domain) { - if (domain == null) { - return null; - } - - return UserEntity.builder() - .id(domain.id()) - .name(domain.name()) - .build(); - } } diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/mapper/MovieMapper.java b/module-persistence/src/main/java/com/hanghae/module/persistence/mapper/MovieMapper.java new file mode 100644 index 00000000..f1ff1dcf --- /dev/null +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/mapper/MovieMapper.java @@ -0,0 +1,32 @@ +package com.hanghae.module.persistence.mapper; + +import com.hanghae.module.domain.model.Movie; +import com.hanghae.module.persistence.entity.MovieEntity; +import org.mapstruct.Mapper; +import org.mapstruct.factory.Mappers; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface MovieMapper { + + /** + * 영화 엔티티를 도메인 모델로 변환 + */ + Movie toDomain(MovieEntity entity); + + /** + * 영화 도메인 모델을 엔티티로 변환 + */ + MovieEntity toEntity(Movie domain); + + /** + * 영화 엔티티 리스트를 도메인 모델 리스트로 변환 + */ + List toDomainList(List entities); + + /** + * 영화 도메인 모델 리스트를 엔티티 리스트로 변환 + */ + List toEntityList(List domains); +} diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/mapper/ReservationMapper.java b/module-persistence/src/main/java/com/hanghae/module/persistence/mapper/ReservationMapper.java new file mode 100644 index 00000000..001944b5 --- /dev/null +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/mapper/ReservationMapper.java @@ -0,0 +1,36 @@ +package com.hanghae.module.persistence.mapper; + +import com.hanghae.module.domain.model.Reservation; +import com.hanghae.module.persistence.entity.ReservationEntity; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface ReservationMapper { + + /** + * 예약 엔티티를 도메인 모델로 변환 + * createdAt은 BaseEntity에서 상속받은 필드라서 명시적으로 매핑 + */ + @Mapping(source = "createdAt", target = "createdAt") + @Mapping(target = "seatNumbers", ignore = true) + Reservation toDomain(ReservationEntity entity); + + /** + * 예약 도메인 모델을 엔티티로 변환 + */ + ReservationEntity toEntity(Reservation domain); + + /** + * 예약 엔티티 리스트를 도메인 모델 리스트로 변환 + */ + List toDomainList(List entities); + + /** + * 예약 도메인 모델 리스트를 엔티티 리스트로 변환 + */ + List toEntityList(List domains); +} diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/mapper/ReservationSeatMapper.java b/module-persistence/src/main/java/com/hanghae/module/persistence/mapper/ReservationSeatMapper.java new file mode 100644 index 00000000..880ca5bb --- /dev/null +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/mapper/ReservationSeatMapper.java @@ -0,0 +1,35 @@ +package com.hanghae.module.persistence.mapper; + +import com.hanghae.module.domain.model.ReservationSeat; +import com.hanghae.module.persistence.entity.ReservationSeatEntity; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface ReservationSeatMapper { + + /** + * 예약좌석 엔티티를 도메인 모델로 변환 + * createdAt을 reservedAt으로 매핑 + */ + @Mapping(source = "createdAt", target = "reservedAt") + ReservationSeat toDomain(ReservationSeatEntity entity); + + /** + * 예약좌석 도메인 모델을 엔티티로 변환 + */ + ReservationSeatEntity toEntity(ReservationSeat domain); + + /** + * 예약좌석 엔티티 리스트를 도메인 모델 리스트로 변환 + */ + List toDomainList(List entities); + + /** + * 예약좌석 도메인 모델 리스트를 엔티티 리스트로 변환 + */ + List toEntityList(List domains); +} diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/mapper/ScreeningMapper.java b/module-persistence/src/main/java/com/hanghae/module/persistence/mapper/ScreeningMapper.java new file mode 100644 index 00000000..264623d9 --- /dev/null +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/mapper/ScreeningMapper.java @@ -0,0 +1,33 @@ +package com.hanghae.module.persistence.mapper; + +import com.hanghae.module.domain.model.Screening; +import com.hanghae.module.persistence.entity.ScreeningEntity; +import org.mapstruct.Mapper; +import org.mapstruct.factory.Mappers; + +import java.util.List; +import java.util.Optional; + +@Mapper(componentModel = "spring") +public interface ScreeningMapper { + + /** + * 상영 엔티티를 도메인 모델로 변환 + */ + Screening toDomain(ScreeningEntity entity); + + /** + * 상영 도메인 모델을 엔티티로 변환 + */ + ScreeningEntity toEntity(Screening domain); + + /** + * 상영 엔티티 리스트를 도메인 모델 리스트로 변환 + */ + List toDomainList(List entities); + + /** + * 상영 도메인 모델 리스트를 엔티티 리스트로 변환 + */ + List toEntityList(List domains); +} diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/mapper/SeatMapper.java b/module-persistence/src/main/java/com/hanghae/module/persistence/mapper/SeatMapper.java new file mode 100644 index 00000000..26dc0f21 --- /dev/null +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/mapper/SeatMapper.java @@ -0,0 +1,30 @@ +package com.hanghae.module.persistence.mapper; + +import com.hanghae.module.domain.model.Seat; +import com.hanghae.module.persistence.entity.SeatEntity; +import org.mapstruct.Mapper; +import java.util.List; + +@Mapper(componentModel = "spring") +public interface SeatMapper { + + /** + * 좌석 엔티티를 도메인 모델로 변환 + */ + Seat toDomain(SeatEntity entity); + + /** + * 좌석 도메인 모델을 엔티티로 변환 + */ + SeatEntity toEntity(Seat domain); + + /** + * 좌석 엔티티 리스트를 도메인 모델 리스트로 변환 + */ + List toDomainList(List entities); + + /** + * 좌석 도메인 모델 리스트를 엔티티 리스트로 변환 + */ + List toEntityList(List domains); +} diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/mapper/TheaterMapper.java b/module-persistence/src/main/java/com/hanghae/module/persistence/mapper/TheaterMapper.java new file mode 100644 index 00000000..93a4e502 --- /dev/null +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/mapper/TheaterMapper.java @@ -0,0 +1,32 @@ +package com.hanghae.module.persistence.mapper; + +import com.hanghae.module.domain.model.Theater; +import com.hanghae.module.persistence.entity.TheaterEntity; +import org.mapstruct.Mapper; +import org.mapstruct.factory.Mappers; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface TheaterMapper { + + /** + * 극장 엔티티를 도메인 모델로 변환 + */ + Theater toDomain(TheaterEntity entity); + + /** + * 극장 도메인 모델을 엔티티로 변환 + */ + TheaterEntity toEntity(Theater domain); + + /** + * 극장 엔티티 리스트를 도메인 모델 리스트로 변환 + */ + List toDomainList(List entities); + + /** + * 극장 도메인 모델 리스트를 엔티티 리스트로 변환 + */ + List toEntityList(List domains); +} diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/mapper/UserMapper.java b/module-persistence/src/main/java/com/hanghae/module/persistence/mapper/UserMapper.java new file mode 100644 index 00000000..d19f40e1 --- /dev/null +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/mapper/UserMapper.java @@ -0,0 +1,33 @@ +package com.hanghae.module.persistence.mapper; + + +import com.hanghae.module.domain.model.User; +import com.hanghae.module.persistence.entity.UserEntity; +import org.mapstruct.Mapper; +import org.mapstruct.factory.Mappers; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface UserMapper { + + /** + * 사용자 엔티티를 도메인 모델로 변환 + */ + User toDomain(UserEntity entity); + + /** + * 사용자 도메인 모델을 엔티티로 변환 + */ + UserEntity toEntity(User domain); + + /** + * 사용자 엔티티 리스트를 도메인 모델 리스트로 변환 + */ + List toDomainList(List entities); + + /** + * 사용자 도메인 모델 리스트를 엔티티 리스트로 변환 + */ + List toEntityList(List domains); +} diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/repository/impl/ReservationRepositoryImpl.java b/module-persistence/src/main/java/com/hanghae/module/persistence/repository/impl/ReservationRepositoryImpl.java new file mode 100644 index 00000000..c7c990ce --- /dev/null +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/repository/impl/ReservationRepositoryImpl.java @@ -0,0 +1,22 @@ +package com.hanghae.module.persistence.repository.impl; + +import com.hanghae.module.domain.model.Reservation; +import com.hanghae.module.domain.repository.ReservationRepository; +import com.hanghae.module.persistence.entity.ReservationEntity; +import com.hanghae.module.persistence.mapper.ReservationMapper; +import com.hanghae.module.persistence.repository.jpa.ReservationJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ReservationRepositoryImpl implements ReservationRepository { + private final ReservationJpaRepository reservationJpaRepository; + private final ReservationMapper reservationMapper; + + @Override + public Reservation save(Reservation reservation) { + ReservationEntity entity = reservationJpaRepository.save(reservationMapper.toEntity(reservation)); + return reservationMapper.toDomain(entity); + } +} diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/repository/impl/ReservationSeatRepositoryImpl.java b/module-persistence/src/main/java/com/hanghae/module/persistence/repository/impl/ReservationSeatRepositoryImpl.java new file mode 100644 index 00000000..76b782e9 --- /dev/null +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/repository/impl/ReservationSeatRepositoryImpl.java @@ -0,0 +1,23 @@ +package com.hanghae.module.persistence.repository.impl; + +import com.hanghae.module.domain.model.ReservationSeat; +import com.hanghae.module.domain.repository.ReservationSeatRepository; +import com.hanghae.module.persistence.entity.ReservationSeatEntity; +import com.hanghae.module.persistence.mapper.ReservationSeatMapper; +import com.hanghae.module.persistence.repository.jpa.ReservationSeatJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ReservationSeatRepositoryImpl implements ReservationSeatRepository { + private final ReservationSeatJpaRepository reservationSeatJpaRepository; + private final ReservationSeatMapper reservationSeatMapper; + + @Override + public void save(ReservationSeat reservationSeat) { + reservationSeatJpaRepository.save(reservationSeatMapper.toEntity(reservationSeat)); + } + + +} diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/repository/impl/ScreeningRepositoryImpl.java b/module-persistence/src/main/java/com/hanghae/module/persistence/repository/impl/ScreeningRepositoryImpl.java new file mode 100644 index 00000000..5dc58e50 --- /dev/null +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/repository/impl/ScreeningRepositoryImpl.java @@ -0,0 +1,23 @@ +package com.hanghae.module.persistence.repository.impl; + +import com.hanghae.module.domain.model.Screening; +import com.hanghae.module.domain.repository.ScreeningRepository; +import com.hanghae.module.persistence.entity.ScreeningEntity; +import com.hanghae.module.persistence.mapper.ScreeningMapper; +import com.hanghae.module.persistence.repository.jpa.ScreeningJpaRepository; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ScreeningRepositoryImpl implements ScreeningRepository { + private final ScreeningJpaRepository screeningJpaRepository; + private final ScreeningMapper screeningMapper; + @Override + public Screening findById(Long id) { + ScreeningEntity entity = screeningJpaRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("존재하지 않는 상영 정보입니다.")); + return screeningMapper.toDomain(entity); + } +} diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/repository/impl/SeatRepositoryImpl.java b/module-persistence/src/main/java/com/hanghae/module/persistence/repository/impl/SeatRepositoryImpl.java new file mode 100644 index 00000000..1048d0ac --- /dev/null +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/repository/impl/SeatRepositoryImpl.java @@ -0,0 +1,29 @@ +package com.hanghae.module.persistence.repository.impl; + +import com.hanghae.module.domain.model.Seat; +import com.hanghae.module.domain.repository.SeatRepository; +import com.hanghae.module.persistence.entity.SeatEntity; +import com.hanghae.module.persistence.mapper.SeatMapper; +import com.hanghae.module.persistence.repository.jpa.SeatJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class SeatRepositoryImpl implements SeatRepository { + private final SeatJpaRepository seatJpaRepository; + private final SeatMapper seatMapper; + + @Override + public List getSeats(Long screening, List seatNumbers) { + List entities = seatJpaRepository.findByScreeningAndSeatNumberIn(screening, seatNumbers); + return seatMapper.toDomainList(entities); + } + + @Override + public void saveAll(List seats) { + + } +} diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/repository/jpa/ReservationJpaRepository.java b/module-persistence/src/main/java/com/hanghae/module/persistence/repository/jpa/ReservationJpaRepository.java new file mode 100644 index 00000000..5fbc724f --- /dev/null +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/repository/jpa/ReservationJpaRepository.java @@ -0,0 +1,7 @@ +package com.hanghae.module.persistence.repository.jpa; + +import com.hanghae.module.persistence.entity.ReservationEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReservationJpaRepository extends JpaRepository { +} diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/repository/jpa/ReservationSeatJpaRepository.java b/module-persistence/src/main/java/com/hanghae/module/persistence/repository/jpa/ReservationSeatJpaRepository.java new file mode 100644 index 00000000..67b8549f --- /dev/null +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/repository/jpa/ReservationSeatJpaRepository.java @@ -0,0 +1,8 @@ +package com.hanghae.module.persistence.repository.jpa; + +import com.hanghae.module.persistence.entity.ReservationSeatEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReservationSeatJpaRepository extends JpaRepository { + +} diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/repository/jpa/ScreeningJpaRepository.java b/module-persistence/src/main/java/com/hanghae/module/persistence/repository/jpa/ScreeningJpaRepository.java new file mode 100644 index 00000000..3381d219 --- /dev/null +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/repository/jpa/ScreeningJpaRepository.java @@ -0,0 +1,7 @@ +package com.hanghae.module.persistence.repository.jpa; + +import com.hanghae.module.persistence.entity.ScreeningEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ScreeningJpaRepository extends JpaRepository { +} diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/repository/jpa/SeatJpaRepository.java b/module-persistence/src/main/java/com/hanghae/module/persistence/repository/jpa/SeatJpaRepository.java new file mode 100644 index 00000000..ed0a549c --- /dev/null +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/repository/jpa/SeatJpaRepository.java @@ -0,0 +1,10 @@ +package com.hanghae.module.persistence.repository.jpa; + +import com.hanghae.module.persistence.entity.SeatEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface SeatJpaRepository extends JpaRepository { + List findByScreeningAndSeatNumberIn(Long screeningId, List seatNumbers); +} diff --git a/module-persistence/src/main/java/com/hanghae/module/persistence/repository/querydsl/MovieCustomRepository.java b/module-persistence/src/main/java/com/hanghae/module/persistence/repository/querydsl/MovieCustomRepository.java index 5d0a75e6..276e89ab 100644 --- a/module-persistence/src/main/java/com/hanghae/module/persistence/repository/querydsl/MovieCustomRepository.java +++ b/module-persistence/src/main/java/com/hanghae/module/persistence/repository/querydsl/MovieCustomRepository.java @@ -15,6 +15,7 @@ import org.springframework.util.StringUtils; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; @@ -33,7 +34,7 @@ public List findAllNowPlayingMovies(Long theaterId, String title, Genr QScreeningEntity screening = QScreeningEntity.screeningEntity; QTheaterEntity theater = QTheaterEntity.theaterEntity; - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now().truncatedTo(ChronoUnit.DAYS); // 영화와 상영 정보를 조인하고, 특정 극장 ID로 필터링 (선택적) List movies = queryFactory @@ -42,7 +43,7 @@ public List findAllNowPlayingMovies(Long theaterId, String title, Genr .join(theater).on(screening.theater.eq(theater.id)) .where( // 현재 상영 중인 영화 조건 - screening.startTime.after(now), + screening.startTime.goe(now).and(screening.startTime.lt(now.plusWeeks(2))), eqTheaterId(screening, theaterId), eqTitle(movie, title), eqGenre(movie, genre)