diff --git a/README.md b/README.md index b8f0604e4..60ab06801 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,29 @@ > Mysql > Redis +### Junit5 테스트 환경 +* DB : H2 +* Redis : Testcontainers + +### jacoco 보고서 +![jacoco_test_report.png](docs%2Fimg%2Fjacoco_test_report.png) + +### jacoco_report_aggregation 보고서 +![jacoco_report_aggregation.png](docs%2Fimg%2Fjacoco_report_aggregation.png) + +### JaCoCo +* `build.gradle.kts` 파일 `plugins`에 `id("jacoco")` 추가 후 `gradle`을 다시 빌드해야 한다. +* 그 뒤 하단에 `jacoco`, `tasks.jacocoTestCoverageVerification`, `tasks.jacocoTestReport`를 작성 + * 빌드를 다시하지 않으면 빨간줄이 뜬다. +* 테스트 실행 및 보고서 생성 : `./gradlew clean test jacocoTestReport --console verbose` +* 테스트 실행 및 보고서 생성(커버리지 충족 확인) : `./gradlew clean test jacocoTestReport jacocoTestCoverageVerification --console verbose` +* `build.gradle.kts`파일에 보고서 저장경로를 설정하여 `/build/reports/jacoco/index.html` 해당파일을 확인하면 된다. + * `html.outputLocation.set(layout.buildDirectory.dir("reports/jacoco")) //저장경로 설정` +* jacoco-report-aggregation 플러그인을 사용해 멀티모듈의 보고서를 하나로 묶어서 생성 가능 +* jacoco-report-aggregation 플러그인 사용시 명령어 : `./gradlew testCodeCoverageReport` + * 저장 경로 : 위 플러그인 설정한 모듈 build 폴더 + * `cinema-adapter/build/reports/jacoco/testCodeCoverageReport/html/index.html` + ### 규칙 * `infrastruct` 계층에서의 결과값은 `domain model` 혹은 `Projection(필요한 속성만 조회)` 객체 로 리턴한다. * `domain model`에서 `Dto`로 변환은 `application` 계층에서 한다. @@ -471,7 +494,12 @@ constant_load ✓ [======================================] 000/100 VUs 10m0s 1 * 로그 확인 : $docker-compose logs -f // `-f`옵션을 주면 실시간 * 특정 서비스 시작 : $docker-compose start <서비스 이름> * 특정 서비스 종료 : $docker-compose stop <서비스 이름> - + * redis 접속 : docker exec -it redis_cinema redis-cli // redis_cinema = 컨테이너명 + * redis 비밀번호 있는 경우 위 명령어 후 : AUTH 비밀번호 + * 현재 존재하는 키 전체보기 : KEYS * + * 특정키 조회 : KEYS rate_limit:0:0:0:0:0:0:0:1 + * 특정 패턴 조회 : KEYS rate_limit:* + * 모든키 삭제 : FLUSHALL -------------------------------------------------------------- ### 적용 아키텍처 diff --git a/cinema-adapter/build.gradle.kts b/cinema-adapter/build.gradle.kts index 63fb3df72..e02671203 100644 --- a/cinema-adapter/build.gradle.kts +++ b/cinema-adapter/build.gradle.kts @@ -2,11 +2,18 @@ plugins { id("java") id("org.springframework.boot") version "3.4.1" id("io.spring.dependency-management") version "1.1.7" + id("java-test-fixtures") // 테스트 픽스처 활성화 + //id("jacoco") // JaCoCo 플러그인 추가 + id("jacoco-report-aggregation") // JaCoCo 멀티모듈 리포트 통합하기 위한 플러그인 (jacoco 플러그인을 내부적으로 포함) } group = "com.hanghae" version = "0.0.1-SNAPSHOT" +jacoco { + toolVersion = "0.8.10" +} + repositories { mavenCentral() } @@ -16,8 +23,57 @@ dependencies { implementation(project(":cinema-infrastructure")) // RepositoryPort 구현체를 찾지 못해서 추가 implementation("org.springframework.boot:spring-boot-starter-web") // web implementation("org.springframework.boot:spring-boot-starter-data-jpa") //jpa 레포지토리 의존성 문제로 추가 (@EnableJpaRepositories) + testFixturesImplementation(project(":cinema-domain")) // testFixtures에서 도메인 객체 사용 위함 + testFixturesImplementation(project(":cinema-application")) // testFixtures에서 application 객체 사용 위함 + // testFixturesImplementation로 의존성 추가한 계층은 /src/testFixtures 하위 경로에서만 사용 가능 하다. + // 양쪽 계층에 테스트 픽스처 활성화 설정(id("java-test-fixtures"))이 되어 있어야 한다. + + testImplementation("org.springframework.boot:spring-boot-starter-data-redis") // test 코드에서 RedisTemplate 사용 위함 + + testImplementation ("org.testcontainers:testcontainers:1.19.3") //테스트 컨테이너 + testImplementation ("org.testcontainers:junit-jupiter:1.19.3") + + // JaCoCo Report Aggregation에서만 특정 모듈을 포함하도록 설정 + jacocoAggregation(project(":cinema-domain")) + jacocoAggregation(project(":cinema-application")) + jacocoAggregation(project(":cinema-infrastructure")) + jacocoAggregation(project(":cinema-adapter")) } tasks.test { useJUnitPlatform() -} \ No newline at end of file +} + +//jacoco-report-aggregation 관련 추가 +tasks.check { + dependsOn(tasks.named("testCodeCoverageReport")) // <2> +} + + +tasks.jacocoTestReport { + dependsOn(tasks.test) // test 실행 후 리포트 생성 + + reports { + xml.required.set(false) // XML 리포트 비활성화 + csv.required.set(false) // csv 리포트 비활성화 + html.required.set(true) // HTML 리포트 활성화 + html.outputLocation.set(layout.buildDirectory.dir("reports/jacoco")) //저장경로 설정 + } +} + +tasks.jacocoTestCoverageVerification { + dependsOn(tasks.jacocoTestReport) + + violationRules { + rule { + element = "CLASS" + limit { + counter = "BRANCH" + value = "COVEREDRATIO" + //minimum = BigDecimal("0.60") // 최소 60% 커버리지 필요 + //excludes = listOf("com.hanghae.adapter.web.MovieController") // 테스트코드 미구현으로 검증 제외 + minimum = BigDecimal("0.0") // 테스트코드 미구현으로 검증 제외 + } + } + } +} diff --git a/cinema-adapter/src/main/java/com/hanghae/adapter/web/MovieController.java b/cinema-adapter/src/main/java/com/hanghae/adapter/web/MovieController.java index 17f79ef43..0343baf61 100644 --- a/cinema-adapter/src/main/java/com/hanghae/adapter/web/MovieController.java +++ b/cinema-adapter/src/main/java/com/hanghae/adapter/web/MovieController.java @@ -1,9 +1,13 @@ package com.hanghae.adapter.web; import com.hanghae.application.dto.*; -import com.hanghae.application.port.in.MovieReservationService; +import com.hanghae.application.dto.request.MovieScheduleRequestDto; +import com.hanghae.application.dto.response.MovieScheduleResponseDto; +import com.hanghae.application.dto.response.ShowingMovieScheduleResponseDto; import com.hanghae.application.port.in.MovieScheduleService; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -13,30 +17,36 @@ @RequestMapping("/api") public class MovieController { private final MovieScheduleService movieScheduleService; - private final MovieReservationService movieReservationService; //영화 상영 시간표 조회 @GetMapping("/v1/movie-schedules") - public ApiResponse> getMovieSchedules() { - return movieScheduleService.getMovieSchedules(); + public ResponseEntity>> getMovieSchedules() { + ApiResponse> response = movieScheduleService.getMovieSchedules(); + + //응답코드 일치시켜서 리턴 + return ResponseEntity.status(response.status().getCode()).body(response); } //영화별 상영 시간표 조회 (grouping) @GetMapping("/v2/movie-schedules") - public ApiResponse> getShowingMovieSchedules(@ModelAttribute MovieScheduleRequestDto requestDto) { - return movieScheduleService.getShowingMovieSchedules(requestDto); - } + public ResponseEntity>> getShowingMovieSchedules(@ModelAttribute MovieScheduleRequestDto requestDto, HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } - //영화 예약 - // TODO :: 좌석선택API와 영화예약API 분리 - @PostMapping("/v1/movie-reservation") - public ApiResponse saveMovieReservation(@RequestBody MovieReservationRequestDto requestDto) { - return movieReservationService.saveMovieReservation(requestDto); + ApiResponse> response = movieScheduleService.getShowingMovieSchedules(requestDto, ip); + + //응답코드 일치시켜서 리턴 + return ResponseEntity.status(response.status().getCode()).body(response); } //redis 캐시삭제 (테스트용) @GetMapping("/test/evict-cache") - public ApiResponse evictCache() { - return movieScheduleService.evictShowingMovieCache(); + public ResponseEntity> evictCache() { + ApiResponse response = movieScheduleService.evictShowingMovieCache(); + + //응답코드 일치시켜서 리턴 + return ResponseEntity.status(response.status().getCode()).body(response); } } diff --git a/cinema-adapter/src/main/java/com/hanghae/adapter/web/ReservationController.java b/cinema-adapter/src/main/java/com/hanghae/adapter/web/ReservationController.java new file mode 100644 index 000000000..332580890 --- /dev/null +++ b/cinema-adapter/src/main/java/com/hanghae/adapter/web/ReservationController.java @@ -0,0 +1,29 @@ +package com.hanghae.adapter.web; + +import com.hanghae.application.dto.ApiResponse; +import com.hanghae.application.dto.request.MovieReservationRequestDto; +import com.hanghae.application.enums.HttpStatusCode; +import com.hanghae.application.port.in.MovieReservationService; +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; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api") +public class ReservationController { + private final MovieReservationService movieReservationService; + + //영화 예약 + // TODO :: 좌석선택API와 영화예약API 분리 + @PostMapping("/v1/movie-reservation") + public ResponseEntity> saveMovieReservation(@RequestBody MovieReservationRequestDto requestDto) { + ApiResponse response = movieReservationService.saveMovieReservation(requestDto); + + //응답코드 일치시켜서 리턴 + return ResponseEntity.status(response.status().getCode()).body(response); + } +} diff --git a/cinema-adapter/src/main/java/com/hanghae/adapter/web/exception/GlobalExceptionHandler.java b/cinema-adapter/src/main/java/com/hanghae/adapter/web/exception/GlobalExceptionHandler.java index 45584144b..baf5428d4 100644 --- a/cinema-adapter/src/main/java/com/hanghae/adapter/web/exception/GlobalExceptionHandler.java +++ b/cinema-adapter/src/main/java/com/hanghae/adapter/web/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package com.hanghae.adapter.web.exception; import com.hanghae.application.dto.ApiResponse; +import com.hanghae.application.enums.HttpStatusCode; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -14,21 +15,31 @@ public class GlobalExceptionHandler { @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException e) { log.error("IllegalArgumentException occurred: {}", e.getMessage(), e); // 로그 추가 - ApiResponse apiResponse = ApiResponse.of(e.getMessage(), HttpStatus.BAD_REQUEST.value()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(apiResponse); + ApiResponse apiResponse = ApiResponse.of(e.getMessage(), HttpStatusCode.BAD_REQUEST); + return ResponseEntity.status(HttpStatusCode.BAD_REQUEST.getCode()).body(apiResponse); } @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity> handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { log.error("HttpMessageNotReadableException occurred: {}", e.getMessage(), e); // 로그 추가 - ApiResponse apiResponse = ApiResponse.of(e.getMessage(), HttpStatus.BAD_REQUEST.value()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(apiResponse); + ApiResponse apiResponse = ApiResponse.of(e.getMessage(), HttpStatusCode.BAD_REQUEST); + return ResponseEntity.status(HttpStatusCode.BAD_REQUEST.getCode()).body(apiResponse); } @ExceptionHandler(RuntimeException.class) public ResponseEntity> handleRuntimeException(RuntimeException e) { log.error("RuntimeException occurred: {}", e.getMessage(), e); // 로그 추가 - ApiResponse apiResponse = ApiResponse.of(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR.value()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(apiResponse); + ApiResponse apiResponse = ApiResponse.of(e.getMessage(), HttpStatusCode.INTERNAL_SERVER_ERROR); + return ResponseEntity.status(HttpStatusCode.INTERNAL_SERVER_ERROR.getCode()).body(apiResponse); + } + + /** + * 모든 예외를 처리하는 범용 핸들러 (위의 예외에 해당하지 않는 경우) + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGenericException(Exception e) { + log.error("Unhandled exception occurred: {}", e.getMessage(), e); + ApiResponse apiResponse = ApiResponse.of("서버 내부에 오류가 발생했습니다.", HttpStatusCode.INTERNAL_SERVER_ERROR); + return ResponseEntity.status(HttpStatusCode.INTERNAL_SERVER_ERROR.getCode()).body(apiResponse); } } diff --git a/cinema-adapter/src/main/resources/application-test.properties b/cinema-adapter/src/main/resources/application-test.properties new file mode 100644 index 000000000..c570ba014 --- /dev/null +++ b/cinema-adapter/src/main/resources/application-test.properties @@ -0,0 +1,24 @@ +# H2 +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MySQL +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=test_user +spring.datasource.password=test_password +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect + +# h2 (http://localhost:8080/h2-console) +spring.h2.console.enabled=true +spring.h2.console.path=/h2-console + +# JPA +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect +spring.jpa.generate-ddl=true +spring.jpa.open-in-view=false + +# log +#logging.level.root=info + + + diff --git a/cinema-adapter/src/main/resources/sql/reservationTest.sql b/cinema-adapter/src/main/resources/sql/reservationTest.sql new file mode 100644 index 000000000..8a3d9c3f8 --- /dev/null +++ b/cinema-adapter/src/main/resources/sql/reservationTest.sql @@ -0,0 +1,50 @@ +INSERT INTO member (member_id, birth_date, created_by, created_at) +VALUES (1, '1999-01-01', 99, NOW()); + +INSERT INTO upload_file (file_id, file_path, file_name, origin_file_name, created_by, created_at) +VALUES (1, '/radis/test/', 'test1.png', 'origin.png', 99, NOW()); + +INSERT INTO upload_file (file_id, file_path, file_name, origin_file_name, created_by, created_at) +VALUES (2, '/radis/test/', 'test2.png', 'origin.png', 99, NOW()); + +INSERT INTO upload_file (file_id, file_path, file_name, origin_file_name, created_by, created_at) +VALUES (3, '/radis/test/', 'test3.png', 'origin.png', 99, NOW()); + +INSERT INTO movie (movie_id, file_id, title, rating, release_date, running_time_minutes ,genre, created_by, created_at) +VALUES (1, 1, '치토스', '2', '2024-12-11', 90, 'T', 99, NOW()); + +INSERT INTO movie (movie_id, file_id, title, rating, release_date, running_time_minutes ,genre, created_by, created_at) +VALUES (2, 2, '칸초', '1', '2024-11-17', 120, 'F', 99, NOW()); + +INSERT INTO movie (movie_id, file_id, title, rating, release_date, running_time_minutes ,genre, created_by, created_at) +VALUES (3, 3, '공공칠빵', '3', '2024-10-13', 110, 'A', 99, NOW()); + +INSERT INTO screen (screen_id, screen_name, created_by, created_at) +VALUES (1, '1관', 99, NOW()); + +INSERT INTO screen (screen_id, screen_name, created_by, created_at) +VALUES (2, '2관', 99, NOW()); + +INSERT INTO screening_schedule (schedule_id, movie_id, screen_id, show_start_at, created_by, created_at) +VALUES (1, 1, 1, '2025-02-13 10:50:00', 99, NOW()); + +INSERT INTO screening_schedule (schedule_id, movie_id, screen_id, show_start_at, created_by, created_at) +VALUES (2, 2, 2, '2025-02-20 12:00:00', 99, NOW()); + +INSERT INTO screening_schedule (schedule_id, movie_id, screen_id, show_start_at, created_by, created_at) +VALUES (3, 3, 1, '2025-02-17 13:20:00', 99, NOW()); + +INSERT INTO screen_seat_layout (seat_layout_id, screen_id, seat_row, max_seat_number, created_by, created_at) VALUES (1, 1, 'A', 5, 99, NOW()); +INSERT INTO screen_seat_layout (seat_layout_id, screen_id, seat_row, max_seat_number, created_by, created_at) VALUES (2, 1, 'B', 5, 99, NOW()); +INSERT INTO screen_seat_layout (seat_layout_id, screen_id, seat_row, max_seat_number, created_by, created_at) VALUES (3, 1, 'C', 5, 99, NOW()); +INSERT INTO screen_seat_layout (seat_layout_id, screen_id, seat_row, max_seat_number, created_by, created_at) VALUES (4, 1, 'D', 5, 99, NOW()); +INSERT INTO screen_seat_layout (seat_layout_id, screen_id, seat_row, max_seat_number, created_by, created_at) VALUES (5, 1, 'E', 5, 99, NOW()); + +INSERT INTO screen_seat_layout (seat_layout_id, screen_id, seat_row, max_seat_number, created_by, created_at) VALUES (6, 2, 'A', 5, 99, NOW()); +INSERT INTO screen_seat_layout (seat_layout_id, screen_id, seat_row, max_seat_number, created_by, created_at) VALUES (7, 2, 'B', 5, 99, NOW()); +INSERT INTO screen_seat_layout (seat_layout_id, screen_id, seat_row, max_seat_number, created_by, created_at) VALUES (8, 2, 'C', 5, 99, NOW()); +INSERT INTO screen_seat_layout (seat_layout_id, screen_id, seat_row, max_seat_number, created_by, created_at) VALUES (9, 2, 'D', 5, 99, NOW()); +INSERT INTO screen_seat_layout (seat_layout_id, screen_id, seat_row, max_seat_number, created_by, created_at) VALUES (10, 2, 'E', 5, 99, NOW()); + +INSERT INTO ticket_reservation (ticket_id, schedule_id, screen_seat, member_id, created_by, created_at) +VALUES (1, 1, 'E01', 1, 99, NOW()); \ No newline at end of file diff --git a/cinema-adapter/src/main/resources/static/favicon.ico b/cinema-adapter/src/main/resources/static/favicon.ico new file mode 100644 index 000000000..f9f2ebd69 Binary files /dev/null and b/cinema-adapter/src/main/resources/static/favicon.ico differ diff --git a/cinema-adapter/src/test/java/com/hanghae/adapter/web/ReservationControllerTest.java b/cinema-adapter/src/test/java/com/hanghae/adapter/web/ReservationControllerTest.java new file mode 100644 index 000000000..4bd4143f1 --- /dev/null +++ b/cinema-adapter/src/test/java/com/hanghae/adapter/web/ReservationControllerTest.java @@ -0,0 +1,132 @@ +package com.hanghae.adapter.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hanghae.adapter.TestDataFactory; +import com.hanghae.application.dto.request.MovieReservationRequestDto; +import com.hanghae.application.port.in.MovieReservationService; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + +@SpringBootTest +@ActiveProfiles("test") // application-test.properties 사용 +@AutoConfigureMockMvc +@Testcontainers // TestContainers 사용 +@Transactional +@Sql(scripts = "/sql/reservationTest.sql") // SQL 파일 실행 +class ReservationControllerTest { + private static final String REDIS_PASSWORD = "redisPassword"; // 운영과 동일하게 설정 + /** + * @Container 어노테이션은 Testcontainers가 해당 필드에 Docker 컨테이너를 실행하도록 지시 + * 컨테이너의 시작과 종료를 저희가 직접 호출 하지 않고 테스트의 생명주기와 같이 돌 수 있도록 해줄 + * + * 테스트 컨테이너 사용시 도커가 실행중어야 한다. (도커 데스크탑 실행) + * @Container를 사용하면 TestContainers가 자동으로 컨테이너를 실행 + */ + @Container + private static final GenericContainer redisContainer = + new GenericContainer<>(DockerImageName.parse("redis:7.4.2-alpine")) + .withExposedPorts(6379) // TestContainers에서는 내부적으로 6379 사용 + .withCommand("redis-server --requirepass " + REDIS_PASSWORD); // 비밀번호 설정 + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", redisContainer::getHost); + registry.add("spring.data.redis.port", () -> redisContainer.getMappedPort(6379)); + registry.add("spring.data.redis.password", () -> REDIS_PASSWORD); // 비밀번호 적용 + } + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private MovieReservationService movieReservationService; + + @Autowired + private RedisTemplate redisTemplate; + + private MovieReservationRequestDto requestDto; + + @BeforeEach + void setUp() { + requestDto = TestDataFactory.createMovieReservationRequestDto(); + redisTemplate.getConnectionFactory().getConnection().flushDb(); // Redis 데이터 초기화 + } + + @Test + @DisplayName("[통합] 정상적인 영화 예매 요청 테스트") + void saveMovieReservationSuccess() throws Exception { + // When & Then + mockMvc.perform(post("/api/v1/movie-reservation") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.message").value("예매가 완료 되었습니다.")); + } + + @Test + @DisplayName("[통합] 5분내 동일 일정 영화 예매 예외 발생 테스트") + void saveMovieReservationTooManyRequests() throws Exception { + // 1번 요청을 먼저 수행하여 Redis Rate Limit를 초과시키기 + mockMvc.perform(post("/api/v1/movie-reservation") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isCreated()); + + // 5분 내 동일한 요청을 보내면 예외 발생 확인 + mockMvc.perform(post("/api/v1/movie-reservation") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isTooManyRequests()) + .andExpect(jsonPath("$.message").value("동일 시간대 영화 예매는 5분 후 가능합니다.")); + } + + @Test + @DisplayName("[통합] 좌석이 이미 예매된 경우 예외 발생 테스트") + void saveMovieReservation_SeatAlreadyReserved() throws Exception { + //테스트 sql에서 미리 넣어둔 예매데이터와 동일한 좌석 세팅 + requestDto = TestDataFactory.createMovieReservationRequestDto("E", 1); + + // 동일한 좌석을 다시 예매하면 예외 발생 확인 + mockMvc.perform(post("/api/v1/movie-reservation") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("선택한 좌석은 이미 예매되었습니다.")); + } + + @Test + @DisplayName("[통합] 회원당 예매 좌석 개수 초과시 예외 발생 테스트") + void saveMovieReservationSeatLimitExceeded() throws Exception { + //테스트 sql에서 미리 넣어둔 예매데이터 + 추가로 5좌석 예매 + MovieReservationRequestDto invalidRequest = TestDataFactory.createMovieReservationRequestDto(5); + + // 좌석 5개 초과 예매 요청시 예외 발생 확인 + mockMvc.perform(post("/api/v1/movie-reservation") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("상영시간별 예매는 최대 5개까지 할 수 있습니다.")); + } +} \ No newline at end of file diff --git a/cinema-adapter/src/testFixtures/java/com/hanghae/adapter/TestDataFactory.java b/cinema-adapter/src/testFixtures/java/com/hanghae/adapter/TestDataFactory.java new file mode 100644 index 000000000..806a19c60 --- /dev/null +++ b/cinema-adapter/src/testFixtures/java/com/hanghae/adapter/TestDataFactory.java @@ -0,0 +1,20 @@ +package com.hanghae.adapter; + +import com.hanghae.application.dto.request.MovieReservationRequestDto; +import com.hanghae.domain.model.enums.ScreenSeat; + +public class TestDataFactory { + + // 영화 예매 요청 DTO 생성 + public static MovieReservationRequestDto createMovieReservationRequestDto() { + return new MovieReservationRequestDto(1L, 1L, ScreenSeat.A01, 2); + } + + public static MovieReservationRequestDto createMovieReservationRequestDto(int seatCount) { + return new MovieReservationRequestDto(1L, 1L, ScreenSeat.A01, seatCount); + } + + public static MovieReservationRequestDto createMovieReservationRequestDto(String prefix, int number) { + return new MovieReservationRequestDto(1L, 1L, ScreenSeat.fromRowAndNumber(prefix, number), 1); + } +} diff --git a/cinema-application/build.gradle.kts b/cinema-application/build.gradle.kts index 833110af7..f45033300 100644 --- a/cinema-application/build.gradle.kts +++ b/cinema-application/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("java") id("org.springframework.boot") version "3.4.1" id("io.spring.dependency-management") version "1.1.7" + id("java-test-fixtures") // 테스트 픽스처 활성화 } group = "com.hanghae" @@ -13,7 +14,10 @@ repositories { dependencies { implementation(project(":cinema-domain")) // 도메인 계층 의존성 - implementation("com.fasterxml.jackson.core:jackson-annotations:2.16.0") // Java, JSON 변환 라이브러리 + implementation("com.fasterxml.jackson.core:jackson-annotations:2.16.0") // Java, JSON 변환 라이브러리 + testFixturesImplementation(project(":cinema-domain")) // testFixtures에서 도메인 객체 사용 위함 + // testFixturesImplementation로 의존성 추가한 계층은 /src/testFixtures 하위 경로에서만 사용 가능 하다. + // 양쪽 계층에 테스트 픽스처 활성화 설정(id("java-test-fixtures"))이 되어 있어야 한다. } tasks.test { diff --git a/cinema-application/src/main/java/com/hanghae/application/dto/ApiResponse.java b/cinema-application/src/main/java/com/hanghae/application/dto/ApiResponse.java index 879026eec..d1bcaca66 100644 --- a/cinema-application/src/main/java/com/hanghae/application/dto/ApiResponse.java +++ b/cinema-application/src/main/java/com/hanghae/application/dto/ApiResponse.java @@ -1,22 +1,23 @@ package com.hanghae.application.dto; import com.fasterxml.jackson.annotation.JsonInclude; +import com.hanghae.application.enums.HttpStatusCode; import java.util.List; @JsonInclude(JsonInclude.Include.NON_NULL) //null값 JSON에서 제외 public record ApiResponse ( String message, - int status, + HttpStatusCode status, T data ){ - public static ApiResponse of(String message, int status) { + public static ApiResponse of(String message, HttpStatusCode status) { return new ApiResponse<>(message, status, null); } - public static ApiResponse of(String message, int status, T data) { + public static ApiResponse of(String message, HttpStatusCode status, T data) { if ((data == null || (data instanceof List list && list.isEmpty())) && (message == null || message.isEmpty())) { - return new ApiResponse<>("조회된 결과가 없습니다.", 200, null); + return new ApiResponse<>("조회된 결과가 없습니다.", HttpStatusCode.OK, null); } return new ApiResponse<>(message, status, data); } diff --git a/cinema-application/src/main/java/com/hanghae/application/dto/MovieReservationRequestDto.java b/cinema-application/src/main/java/com/hanghae/application/dto/request/MovieReservationRequestDto.java similarity index 93% rename from cinema-application/src/main/java/com/hanghae/application/dto/MovieReservationRequestDto.java rename to cinema-application/src/main/java/com/hanghae/application/dto/request/MovieReservationRequestDto.java index fabc48631..523582cb8 100644 --- a/cinema-application/src/main/java/com/hanghae/application/dto/MovieReservationRequestDto.java +++ b/cinema-application/src/main/java/com/hanghae/application/dto/request/MovieReservationRequestDto.java @@ -1,4 +1,4 @@ -package com.hanghae.application.dto; +package com.hanghae.application.dto.request; import com.hanghae.domain.model.enums.ScreenSeat; diff --git a/cinema-application/src/main/java/com/hanghae/application/dto/MovieScheduleRequestDto.java b/cinema-application/src/main/java/com/hanghae/application/dto/request/MovieScheduleRequestDto.java similarity index 96% rename from cinema-application/src/main/java/com/hanghae/application/dto/MovieScheduleRequestDto.java rename to cinema-application/src/main/java/com/hanghae/application/dto/request/MovieScheduleRequestDto.java index d7962e887..1ebb07fcc 100644 --- a/cinema-application/src/main/java/com/hanghae/application/dto/MovieScheduleRequestDto.java +++ b/cinema-application/src/main/java/com/hanghae/application/dto/request/MovieScheduleRequestDto.java @@ -1,4 +1,4 @@ -package com.hanghae.application.dto; +package com.hanghae.application.dto.request; import com.hanghae.domain.model.enums.MovieGenre; diff --git a/cinema-application/src/main/java/com/hanghae/application/dto/MovieScheduleResponseDto.java b/cinema-application/src/main/java/com/hanghae/application/dto/response/MovieScheduleResponseDto.java similarity index 91% rename from cinema-application/src/main/java/com/hanghae/application/dto/MovieScheduleResponseDto.java rename to cinema-application/src/main/java/com/hanghae/application/dto/response/MovieScheduleResponseDto.java index a913750f7..f989d2f19 100644 --- a/cinema-application/src/main/java/com/hanghae/application/dto/MovieScheduleResponseDto.java +++ b/cinema-application/src/main/java/com/hanghae/application/dto/response/MovieScheduleResponseDto.java @@ -1,4 +1,4 @@ -package com.hanghae.application.dto; +package com.hanghae.application.dto.response; import java.time.LocalDate; import java.time.LocalDateTime; diff --git a/cinema-application/src/main/java/com/hanghae/application/dto/ShowingMovieScheduleResponseDto.java b/cinema-application/src/main/java/com/hanghae/application/dto/response/ShowingMovieScheduleResponseDto.java similarity index 96% rename from cinema-application/src/main/java/com/hanghae/application/dto/ShowingMovieScheduleResponseDto.java rename to cinema-application/src/main/java/com/hanghae/application/dto/response/ShowingMovieScheduleResponseDto.java index 7e0c8fafb..4ddbc7b1b 100644 --- a/cinema-application/src/main/java/com/hanghae/application/dto/ShowingMovieScheduleResponseDto.java +++ b/cinema-application/src/main/java/com/hanghae/application/dto/response/ShowingMovieScheduleResponseDto.java @@ -1,4 +1,4 @@ -package com.hanghae.application.dto; +package com.hanghae.application.dto.response; import lombok.Builder; import lombok.Getter; diff --git a/cinema-application/src/main/java/com/hanghae/application/enums/HttpStatusCode.java b/cinema-application/src/main/java/com/hanghae/application/enums/HttpStatusCode.java index f20c5cfa4..2bfeaef7f 100644 --- a/cinema-application/src/main/java/com/hanghae/application/enums/HttpStatusCode.java +++ b/cinema-application/src/main/java/com/hanghae/application/enums/HttpStatusCode.java @@ -1,31 +1,84 @@ package com.hanghae.application.enums; +import com.fasterxml.jackson.annotation.JsonFormat; import lombok.Getter; import lombok.RequiredArgsConstructor; @Getter @RequiredArgsConstructor +@JsonFormat(shape = JsonFormat.Shape.OBJECT) // 객체 형태로 변환 (응답받는 곳에서 코드값, 메시지 같이 보여짐) public enum HttpStatusCode { + // 1xx: Informational + CONTINUE(100, "Continue"), + SWITCHING_PROTOCOLS(101, "Switching Protocols"), + PROCESSING(102, "Processing"), + EARLY_HINTS(103, "Early Hints"), + // 2xx: 성공 OK(200, "OK"), CREATED(201, "Created"), ACCEPTED(202, "Accepted"), + NON_AUTHORITATIVE_INFORMATION(203, "Non-Authoritative Information"), NO_CONTENT(204, "No Content"), + RESET_CONTENT(205, "Reset Content"), + PARTIAL_CONTENT(206, "Partial Content"), + MULTI_STATUS(207, "Multi-Status"), + ALREADY_REPORTED(208, "Already Reported"), + IM_USED(226, "IM Used"), + + // 3xx: Redirection + MULTIPLE_CHOICES(300, "Multiple Choices"), + MOVED_PERMANENTLY(301, "Moved Permanently"), + FOUND(302, "Found"), + SEE_OTHER(303, "See Other"), + NOT_MODIFIED(304, "Not Modified"), + USE_PROXY(305, "Use Proxy"), + TEMPORARY_REDIRECT(307, "Temporary Redirect"), + PERMANENT_REDIRECT(308, "Permanent Redirect"), // 4xx: 클라이언트 오류 BAD_REQUEST(400, "Bad Request"), UNAUTHORIZED(401, "Unauthorized"), + PAYMENT_REQUIRED(402, "Payment Required"), FORBIDDEN(403, "Forbidden"), NOT_FOUND(404, "Not Found"), METHOD_NOT_ALLOWED(405, "Method Not Allowed"), + NOT_ACCEPTABLE(406, "Not Acceptable"), + PROXY_AUTHENTICATION_REQUIRED(407, "Proxy Authentication Required"), + REQUEST_TIMEOUT(408, "Request Timeout"), CONFLICT(409, "Conflict"), + GONE(410, "Gone"), + LENGTH_REQUIRED(411, "Length Required"), + PRECONDITION_FAILED(412, "Precondition Failed"), + PAYLOAD_TOO_LARGE(413, "Payload Too Large"), + URI_TOO_LONG(414, "URI Too Long"), + UNSUPPORTED_MEDIA_TYPE(415, "Unsupported Media Type"), + RANGE_NOT_SATISFIABLE(416, "Range Not Satisfiable"), + EXPECTATION_FAILED(417, "Expectation Failed"), + I_AM_A_TEAPOT(418, "I'm a teapot"), + MISDIRECTED_REQUEST(421, "Misdirected Request"), UNPROCESSABLE_ENTITY(422, "Unprocessable Entity"), + LOCKED(423, "Locked"), + FAILED_DEPENDENCY(424, "Failed Dependency"), + TOO_EARLY(425, "Too Early"), + UPGRADE_REQUIRED(426, "Upgrade Required"), + PRECONDITION_REQUIRED(428, "Precondition Required"), + TOO_MANY_REQUESTS(429, "Too Many Requests"), + REQUEST_HEADER_FIELDS_TOO_LARGE(431, "Request Header Fields Too Large"), + UNAVAILABLE_FOR_LEGAL_REASONS(451, "Unavailable For Legal Reasons"), // 5xx: 서버 오류 INTERNAL_SERVER_ERROR(500, "Internal Server Error"), NOT_IMPLEMENTED(501, "Not Implemented"), + BAD_GATEWAY(502, "Bad Gateway"), SERVICE_UNAVAILABLE(503, "Service Unavailable"), - GATEWAY_TIMEOUT(504, "Gateway Timeout"); + GATEWAY_TIMEOUT(504, "Gateway Timeout"), + HTTP_VERSION_NOT_SUPPORTED(505, "HTTP Version Not Supported"), + VARIANT_ALSO_NEGOTIATES(506, "Variant Also Negotiates"), + INSUFFICIENT_STORAGE(507, "Insufficient Storage"), + LOOP_DETECTED(508, "Loop Detected"), + NOT_EXTENDED(510, "Not Extended"), + NETWORK_AUTHENTICATION_REQUIRED(511, "Network Authentication Required"); private final int code; private final String message; diff --git a/cinema-application/src/main/java/com/hanghae/application/port/in/MovieReservationService.java b/cinema-application/src/main/java/com/hanghae/application/port/in/MovieReservationService.java index 6eed90a1c..4836d0f55 100644 --- a/cinema-application/src/main/java/com/hanghae/application/port/in/MovieReservationService.java +++ b/cinema-application/src/main/java/com/hanghae/application/port/in/MovieReservationService.java @@ -1,7 +1,7 @@ package com.hanghae.application.port.in; import com.hanghae.application.dto.ApiResponse; -import com.hanghae.application.dto.MovieReservationRequestDto; +import com.hanghae.application.dto.request.MovieReservationRequestDto; public interface MovieReservationService { public ApiResponse saveMovieReservation(MovieReservationRequestDto requestDto); diff --git a/cinema-application/src/main/java/com/hanghae/application/port/in/MovieScheduleService.java b/cinema-application/src/main/java/com/hanghae/application/port/in/MovieScheduleService.java index 785dfa71f..3285baeb3 100644 --- a/cinema-application/src/main/java/com/hanghae/application/port/in/MovieScheduleService.java +++ b/cinema-application/src/main/java/com/hanghae/application/port/in/MovieScheduleService.java @@ -1,14 +1,14 @@ package com.hanghae.application.port.in; import com.hanghae.application.dto.ApiResponse; -import com.hanghae.application.dto.MovieScheduleRequestDto; -import com.hanghae.application.dto.MovieScheduleResponseDto; -import com.hanghae.application.dto.ShowingMovieScheduleResponseDto; +import com.hanghae.application.dto.request.MovieScheduleRequestDto; +import com.hanghae.application.dto.response.MovieScheduleResponseDto; +import com.hanghae.application.dto.response.ShowingMovieScheduleResponseDto; import java.util.List; public interface MovieScheduleService { ApiResponse> getMovieSchedules(); - ApiResponse> getShowingMovieSchedules(MovieScheduleRequestDto requestDto); + ApiResponse> getShowingMovieSchedules(MovieScheduleRequestDto requestDto, String ip); ApiResponse evictShowingMovieCache(); } diff --git a/cinema-application/src/main/java/com/hanghae/application/port/out/MessagePort.java b/cinema-application/src/main/java/com/hanghae/application/port/out/message/MessagePort.java similarity index 59% rename from cinema-application/src/main/java/com/hanghae/application/port/out/MessagePort.java rename to cinema-application/src/main/java/com/hanghae/application/port/out/message/MessagePort.java index 0c0829145..8cf839cbc 100644 --- a/cinema-application/src/main/java/com/hanghae/application/port/out/MessagePort.java +++ b/cinema-application/src/main/java/com/hanghae/application/port/out/message/MessagePort.java @@ -1,4 +1,4 @@ -package com.hanghae.application.port.out; +package com.hanghae.application.port.out.message; public interface MessagePort { void sendMessage(String message); diff --git a/cinema-application/src/main/java/com/hanghae/application/port/out/redis/RedisRateLimitPort.java b/cinema-application/src/main/java/com/hanghae/application/port/out/redis/RedisRateLimitPort.java new file mode 100644 index 000000000..1167dce4c --- /dev/null +++ b/cinema-application/src/main/java/com/hanghae/application/port/out/redis/RedisRateLimitPort.java @@ -0,0 +1,8 @@ +package com.hanghae.application.port.out.redis; + +public interface RedisRateLimitPort { + boolean isAllowed(String ip); + boolean tryReserveWithLimit(Long scheduleId, Long memberId); + boolean canReserve(Long scheduleId, Long memberId); + void setReservationLimit(Long scheduleId, Long memberId); +} diff --git a/cinema-application/src/main/java/com/hanghae/application/port/out/RedissonLockPort.java b/cinema-application/src/main/java/com/hanghae/application/port/out/redis/RedissonLockPort.java similarity index 87% rename from cinema-application/src/main/java/com/hanghae/application/port/out/RedissonLockPort.java rename to cinema-application/src/main/java/com/hanghae/application/port/out/redis/RedissonLockPort.java index 748c92d6b..b0808d9da 100644 --- a/cinema-application/src/main/java/com/hanghae/application/port/out/RedissonLockPort.java +++ b/cinema-application/src/main/java/com/hanghae/application/port/out/redis/RedissonLockPort.java @@ -1,4 +1,4 @@ -package com.hanghae.application.port.out; +package com.hanghae.application.port.out.redis; import com.hanghae.domain.model.enums.ScreenSeat; diff --git a/cinema-application/src/main/java/com/hanghae/application/port/out/MemberRepositoryPort.java b/cinema-application/src/main/java/com/hanghae/application/port/out/repository/MemberRepositoryPort.java similarity index 68% rename from cinema-application/src/main/java/com/hanghae/application/port/out/MemberRepositoryPort.java rename to cinema-application/src/main/java/com/hanghae/application/port/out/repository/MemberRepositoryPort.java index 7b0d97472..a38ec96be 100644 --- a/cinema-application/src/main/java/com/hanghae/application/port/out/MemberRepositoryPort.java +++ b/cinema-application/src/main/java/com/hanghae/application/port/out/repository/MemberRepositoryPort.java @@ -1,4 +1,4 @@ -package com.hanghae.application.port.out; +package com.hanghae.application.port.out.repository; import com.hanghae.domain.model.Member; diff --git a/cinema-application/src/main/java/com/hanghae/application/port/out/MovieRepositoryPort.java b/cinema-application/src/main/java/com/hanghae/application/port/out/repository/MovieRepositoryPort.java similarity index 68% rename from cinema-application/src/main/java/com/hanghae/application/port/out/MovieRepositoryPort.java rename to cinema-application/src/main/java/com/hanghae/application/port/out/repository/MovieRepositoryPort.java index e8f7a68d8..ea93d829d 100644 --- a/cinema-application/src/main/java/com/hanghae/application/port/out/MovieRepositoryPort.java +++ b/cinema-application/src/main/java/com/hanghae/application/port/out/repository/MovieRepositoryPort.java @@ -1,6 +1,6 @@ -package com.hanghae.application.port.out; +package com.hanghae.application.port.out.repository; -import com.hanghae.application.dto.MovieScheduleRequestDto; +import com.hanghae.application.dto.request.MovieScheduleRequestDto; import com.hanghae.application.projection.MovieScheduleProjection; import java.util.List; diff --git a/cinema-application/src/main/java/com/hanghae/application/port/out/ScreenSeatLayoutRepositoryPort.java b/cinema-application/src/main/java/com/hanghae/application/port/out/repository/ScreenSeatLayoutRepositoryPort.java similarity index 73% rename from cinema-application/src/main/java/com/hanghae/application/port/out/ScreenSeatLayoutRepositoryPort.java rename to cinema-application/src/main/java/com/hanghae/application/port/out/repository/ScreenSeatLayoutRepositoryPort.java index 429f50602..86d24369a 100644 --- a/cinema-application/src/main/java/com/hanghae/application/port/out/ScreenSeatLayoutRepositoryPort.java +++ b/cinema-application/src/main/java/com/hanghae/application/port/out/repository/ScreenSeatLayoutRepositoryPort.java @@ -1,9 +1,7 @@ -package com.hanghae.application.port.out; +package com.hanghae.application.port.out.repository; import com.hanghae.domain.model.ScreenSeatLayout; -import java.util.List; - public interface ScreenSeatLayoutRepositoryPort { ScreenSeatLayout findBySeatRowAndScreenId(String seatRow, Long screenId); } diff --git a/cinema-application/src/main/java/com/hanghae/application/port/out/ScreeningScheduleRepositoryPort.java b/cinema-application/src/main/java/com/hanghae/application/port/out/repository/ScreeningScheduleRepositoryPort.java similarity index 79% rename from cinema-application/src/main/java/com/hanghae/application/port/out/ScreeningScheduleRepositoryPort.java rename to cinema-application/src/main/java/com/hanghae/application/port/out/repository/ScreeningScheduleRepositoryPort.java index dad904472..c829a888c 100644 --- a/cinema-application/src/main/java/com/hanghae/application/port/out/ScreeningScheduleRepositoryPort.java +++ b/cinema-application/src/main/java/com/hanghae/application/port/out/repository/ScreeningScheduleRepositoryPort.java @@ -1,4 +1,4 @@ -package com.hanghae.application.port.out; +package com.hanghae.application.port.out.repository; import com.hanghae.domain.model.ScreeningSchedule; diff --git a/cinema-application/src/main/java/com/hanghae/application/port/out/TicketReservationRepositoryPort.java b/cinema-application/src/main/java/com/hanghae/application/port/out/repository/TicketReservationRepositoryPort.java similarity index 89% rename from cinema-application/src/main/java/com/hanghae/application/port/out/TicketReservationRepositoryPort.java rename to cinema-application/src/main/java/com/hanghae/application/port/out/repository/TicketReservationRepositoryPort.java index 6566ef747..b859b3d06 100644 --- a/cinema-application/src/main/java/com/hanghae/application/port/out/TicketReservationRepositoryPort.java +++ b/cinema-application/src/main/java/com/hanghae/application/port/out/repository/TicketReservationRepositoryPort.java @@ -1,4 +1,4 @@ -package com.hanghae.application.port.out; +package com.hanghae.application.port.out.repository; import com.hanghae.domain.model.TicketReservation; import com.hanghae.domain.model.enums.ScreenSeat; diff --git a/cinema-application/src/main/java/com/hanghae/application/service/MovieReservationServiceImpl.java b/cinema-application/src/main/java/com/hanghae/application/service/MovieReservationServiceImpl.java index 00ad43246..27d45d858 100644 --- a/cinema-application/src/main/java/com/hanghae/application/service/MovieReservationServiceImpl.java +++ b/cinema-application/src/main/java/com/hanghae/application/service/MovieReservationServiceImpl.java @@ -1,10 +1,16 @@ package com.hanghae.application.service; import com.hanghae.application.dto.ApiResponse; -import com.hanghae.application.dto.MovieReservationRequestDto; +import com.hanghae.application.dto.request.MovieReservationRequestDto; import com.hanghae.application.enums.HttpStatusCode; import com.hanghae.application.port.in.MovieReservationService; -import com.hanghae.application.port.out.*; +import com.hanghae.application.port.out.message.MessagePort; +import com.hanghae.application.port.out.redis.RedisRateLimitPort; +import com.hanghae.application.port.out.redis.RedissonLockPort; +import com.hanghae.application.port.out.repository.MemberRepositoryPort; +import com.hanghae.application.port.out.repository.ScreenSeatLayoutRepositoryPort; +import com.hanghae.application.port.out.repository.ScreeningScheduleRepositoryPort; +import com.hanghae.application.port.out.repository.TicketReservationRepositoryPort; import com.hanghae.domain.model.Member; import com.hanghae.domain.model.ScreenSeatLayout; import com.hanghae.domain.model.ScreeningSchedule; @@ -27,6 +33,7 @@ public class MovieReservationServiceImpl implements MovieReservationService { private final MessagePort messagePort; private final ReservationService reservationService; private final RedissonLockPort redissonLockPort; // Redisson 분산락 사용 + private final RedisRateLimitPort redisRateLimitPort; @Override @Transactional @@ -36,6 +43,11 @@ public ApiResponse saveMovieReservation(MovieReservationRequestDto request int seatCount = requestDto.seatCount(); ScreenSeat screenSeat = requestDto.screenSeat(); + //동 시간대의 영화를 5분에 1번씩 예매 제한 + if (!redisRateLimitPort.canReserve(scheduleId, memberId)) { + return ApiResponse.of("동일 시간대 영화 예매는 5분 후 가능합니다.", HttpStatusCode.TOO_MANY_REQUESTS); + } + // 예매할 좌석 목록 가져오기 List selectedSeats = ScreenSeat.getSelectedConnectedSeats(screenSeat, seatCount); @@ -66,13 +78,16 @@ public ApiResponse saveMovieReservation(MovieReservationRequestDto request // 예매 내역 저장 ticketReservationRepositoryPort.saveMovieReservations(ticketReservations); + // 예매 성공 후 Redis 제한 설정 + redisRateLimitPort.setReservationLimit(scheduleId, memberId); + //완료 메시지 전송 (비동기) messagePort.sendMessage("영화 예매가 완료 되었습니다."); - return ApiResponse.of("예매가 완료 되었습니다.", HttpStatusCode.CREATED.getCode()); + return ApiResponse.of("예매가 완료 되었습니다.", HttpStatusCode.CREATED); }); } catch (IllegalStateException e) { - return ApiResponse.of("현재 좌석을 다른 사용자가 예매 처리 중입니다.", HttpStatusCode.CONFLICT.getCode()); + return ApiResponse.of("현재 좌석을 다른 사용자가 예매 처리 중입니다.", HttpStatusCode.CONFLICT); } } } diff --git a/cinema-application/src/main/java/com/hanghae/application/service/MovieScheduleServiceImpl.java b/cinema-application/src/main/java/com/hanghae/application/service/MovieScheduleServiceImpl.java index 343245381..34dadbffa 100644 --- a/cinema-application/src/main/java/com/hanghae/application/service/MovieScheduleServiceImpl.java +++ b/cinema-application/src/main/java/com/hanghae/application/service/MovieScheduleServiceImpl.java @@ -1,13 +1,14 @@ package com.hanghae.application.service; import com.hanghae.application.dto.ApiResponse; -import com.hanghae.application.dto.MovieScheduleRequestDto; -import com.hanghae.application.dto.MovieScheduleResponseDto; -import com.hanghae.application.dto.ShowingMovieScheduleResponseDto; +import com.hanghae.application.dto.request.MovieScheduleRequestDto; +import com.hanghae.application.dto.response.MovieScheduleResponseDto; +import com.hanghae.application.dto.response.ShowingMovieScheduleResponseDto; import com.hanghae.application.enums.HttpStatusCode; import com.hanghae.application.port.in.MovieScheduleService; -import com.hanghae.application.port.out.MovieRepositoryPort; -import com.hanghae.application.port.out.ScreeningScheduleRepositoryPort; +import com.hanghae.application.port.out.redis.RedisRateLimitPort; +import com.hanghae.application.port.out.repository.MovieRepositoryPort; +import com.hanghae.application.port.out.repository.ScreeningScheduleRepositoryPort; import com.hanghae.application.projection.MovieScheduleProjection; import com.hanghae.domain.model.Movie; import com.hanghae.domain.model.ScreeningSchedule; @@ -25,6 +26,7 @@ public class MovieScheduleServiceImpl implements MovieScheduleService { private final ScreeningScheduleRepositoryPort screeningScheduleRepositoryPort; private final MovieRepositoryPort movieRepositoryPort; + private final RedisRateLimitPort redisRateLimitPort; @Override @Transactional @@ -32,12 +34,17 @@ public ApiResponse> getMovieSchedules() { List schedules = screeningScheduleRepositoryPort.findAll(); List responseDtos = schedules.stream().map(this::convertToDto).collect(Collectors.toList()); - return ApiResponse.of("Success", HttpStatusCode.OK.getCode(), responseDtos); + return ApiResponse.of("Success", HttpStatusCode.OK, responseDtos); } @Override @Transactional - public ApiResponse> getShowingMovieSchedules(MovieScheduleRequestDto requestDto) { + public ApiResponse> getShowingMovieSchedules(MovieScheduleRequestDto requestDto, String ip) { + //1분에 50회 이상 조회시 조회 제한 + if (!redisRateLimitPort.isAllowed(ip)) { + return ApiResponse.of("너무 많은 요청으로 조회가 차단되었습니다. ", HttpStatusCode.TOO_MANY_REQUESTS); + } + List projections = movieRepositoryPort.findShowingMovieSchedules(requestDto); Map movieMap = new LinkedHashMap<>(); @@ -73,13 +80,13 @@ public ApiResponse> getShowingMovieSchedul .build()); } - return ApiResponse.of("Success", HttpStatusCode.OK.getCode(), new ArrayList<>(movieMap.values())); + return ApiResponse.of("Success", HttpStatusCode.OK, new ArrayList<>(movieMap.values())); } @Override public ApiResponse evictShowingMovieCache() { movieRepositoryPort.evictShowingMovieCache(); - return ApiResponse.of("Success", HttpStatusCode.NO_CONTENT.getCode()); + return ApiResponse.of("Success", HttpStatusCode.NO_CONTENT); } private MovieScheduleResponseDto convertToDto(ScreeningSchedule schedule) { diff --git a/cinema-application/src/test/java/com/hanghae/application/TestConfiguration.java b/cinema-application/src/test/java/com/hanghae/application/TestConfiguration.java new file mode 100644 index 000000000..2dcfd22c2 --- /dev/null +++ b/cinema-application/src/test/java/com/hanghae/application/TestConfiguration.java @@ -0,0 +1,11 @@ +package com.hanghae.application; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class TestConfiguration { + /** + * 어뎁터에서 @SpringBootApplication 를 가진 main 메서드를 가지고 있기 떄문에 + * 테스트시 실행을 위해 테스트용 SpringBootApplication 생성 + */ +} diff --git a/cinema-application/src/test/java/com/hanghae/application/service/MovieReservationServiceImplTest.java b/cinema-application/src/test/java/com/hanghae/application/service/MovieReservationServiceImplTest.java new file mode 100644 index 000000000..c9a201d88 --- /dev/null +++ b/cinema-application/src/test/java/com/hanghae/application/service/MovieReservationServiceImplTest.java @@ -0,0 +1,180 @@ +package com.hanghae.application.service; + +import com.hanghae.application.TestDataFactory; +import com.hanghae.application.dto.ApiResponse; +import com.hanghae.application.dto.request.MovieReservationRequestDto; +import com.hanghae.application.enums.HttpStatusCode; +import com.hanghae.application.port.out.message.MessagePort; +import com.hanghae.application.port.out.redis.RedisRateLimitPort; +import com.hanghae.application.port.out.redis.RedissonLockPort; +import com.hanghae.application.port.out.repository.MemberRepositoryPort; +import com.hanghae.application.port.out.repository.ScreenSeatLayoutRepositoryPort; +import com.hanghae.application.port.out.repository.ScreeningScheduleRepositoryPort; +import com.hanghae.application.port.out.repository.TicketReservationRepositoryPort; +import com.hanghae.domain.model.Member; +import com.hanghae.domain.model.ScreenSeatLayout; +import com.hanghae.domain.model.ScreeningSchedule; +import com.hanghae.domain.model.TicketReservation; +import com.hanghae.domain.model.enums.ScreenSeat; +import com.hanghae.domain.service.ReservationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MovieReservationServiceImplTest { + + @InjectMocks + private MovieReservationServiceImpl movieReservationService; + + @Mock + private TicketReservationRepositoryPort ticketReservationRepositoryPort; + @Mock + private ScreeningScheduleRepositoryPort screeningScheduleRepositoryPort; + @Mock + private ScreenSeatLayoutRepositoryPort screenSeatLayoutRepositoryPort; + @Mock + private MemberRepositoryPort memberRepositoryPort; + @Mock + private MessagePort messagePort; + @Mock + private ReservationService reservationService; + @Mock + private RedissonLockPort redissonLockPort; + @Mock + private RedisRateLimitPort redisRateLimitPort; + + private MovieReservationRequestDto requestDto; + private ScreeningSchedule screeningSchedule; + private Member member; + private ScreenSeatLayout screenSeatLayout; + private List selectedSeats; + private List ticketReservations; + + @BeforeEach + void setUp() { + requestDto = TestDataFactory.createMovieReservationRequestDto(); + screeningSchedule = TestDataFactory.createScreeningSchedule(); + member = TestDataFactory.createMember(); + screenSeatLayout = TestDataFactory.createScreenSeatLayout(); + selectedSeats = ScreenSeat.getSelectedConnectedSeats(ScreenSeat.A01, 2); + ticketReservations = TestDataFactory.createTicketReservations(); + + // 예상 동작 정의 + // 필요하지 않은 Stubbing이 있어도 예외 없이 테스트가 진행되게 lenient() 사용 + lenient().when(screeningScheduleRepositoryPort.findById(anyLong())).thenReturn(screeningSchedule); + lenient().when(memberRepositoryPort.findById(anyLong())).thenReturn(member); + lenient().when(screenSeatLayoutRepositoryPort.findBySeatRowAndScreenId(anyString(), anyLong())).thenReturn(screenSeatLayout); + lenient().when(ticketReservationRepositoryPort.countByScreeningScheduleIdAndMemberId(anyLong(), anyLong())).thenReturn(0); + lenient().when(ticketReservationRepositoryPort.countByScheduleIdAndScreenSeats(anyLong(), anyList())).thenReturn(0); + lenient().when(reservationService.createTicketReservations(any(), any(), any(), any(), anyInt())).thenReturn(ticketReservations); + lenient().when(redisRateLimitPort.canReserve(anyLong(), anyLong())).thenReturn(true); + lenient().when(redissonLockPort.executeWithSeatsLocks(anyLong(), anyList(), any())).thenAnswer(invocation -> { + Supplier> supplier = invocation.getArgument(2); + return supplier.get(); + }); + } + + @Test + @DisplayName("정상적인 예매 성공 테스트") + void saveMovieReservationSuccess() { + ApiResponse response = movieReservationService.saveMovieReservation(requestDto); + + // 응답 코드 비교 + assertEquals(HttpStatusCode.CREATED, response.status()); + + //ticketReservationRepositoryPort.saveMovieReservations 메서드가 1회 호출 되었는지 확인 + verify(ticketReservationRepositoryPort, times(1)).saveMovieReservations(anyList()); + + //messagePort.sendMessage 메서드가 1회 호출 되었는지 확인 + verify(messagePort, times(1)).sendMessage(anyString()); + } + + @Test + @DisplayName("동일 시간대 예매 제한 초과 시 예외 처리 테스트") + void saveMovieReservationTooManyRequests() { + when(redisRateLimitPort.canReserve(anyLong(), anyLong())).thenReturn(false); + + ApiResponse response = movieReservationService.saveMovieReservation(requestDto); + + // 응답 코드 확인 + assertEquals(HttpStatusCode.TOO_MANY_REQUESTS, response.status()); + + //ticketReservationRepositoryPort.saveMovieReservations 메서드가 호출 되지 않았는지 확인 + verify(ticketReservationRepositoryPort, never()).saveMovieReservations(anyList()); + } + + @Test + @DisplayName("이미 예매된 좌석일 경우 예외 발생 테스트") + void saveMovieReservationSeatAlreadyReserved() { + when(ticketReservationRepositoryPort.countByScheduleIdAndScreenSeats(anyLong(), anyList())).thenReturn(1); + + //validateSeatAvailability 인자로 받은 값이 0보다 크면 예외처리, 아닐 경우 그냥 통과 + doAnswer(invocation -> { + int count = invocation.getArgument(0); + if (count > 0) { + throw new IllegalArgumentException("선택한 좌석은 이미 예매되었습니다."); + } + return null; + }).when(reservationService).validateSeatAvailability(anyInt()); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> + movieReservationService.saveMovieReservation(requestDto)); + + // 응답 메시지 비교 + assertEquals("선택한 좌석은 이미 예매되었습니다.", exception.getMessage()); + } + + @Test + @DisplayName("예매좌석 5개 초과시 예외 발생 테스트") + void saveMovieReservationSeatLimitExceeded() { + when(ticketReservationRepositoryPort.countByScreeningScheduleIdAndMemberId(anyLong(), anyLong())).thenReturn(5); + + //validateReservationSeatLimit 인자로 받은 값의 합이 5보다 크면 예외처리, 아닐 경우 그냥 통과 + doAnswer(invocation -> { + int reservedTicketCount = invocation.getArgument(0); + int seatCount = invocation.getArgument(1); + + if (reservedTicketCount + seatCount > 5) { + throw new IllegalArgumentException("상영시간별 예매는 최대 5개까지 할 수 있습니다."); + } + + return null; // void 메서드이므로 null 반환 + }).when(reservationService).validateReservationSeatLimit(anyInt(), anyInt()); + + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> + movieReservationService.saveMovieReservation(requestDto)); + + // 응답 메시지 확인 + assertEquals("상영시간별 예매는 최대 5개까지 할 수 있습니다.", exception.getMessage()); + } + + @Test + @DisplayName("Redisson Lock을 획득하지 못했을 경우 예외 처리 테스트") + void saveMovieReservationLockFailed() { + // 기존 Mock 설정 제거 + Mockito.reset(redissonLockPort); + + // executeWithSeatsLocks 예상 동작 다시 정의 + when(redissonLockPort.executeWithSeatsLocks(anyLong(), anyList(), any())) + .thenThrow(new IllegalStateException("현재 좌석을 다른 사용자가 예매 처리 중입니다.")); + + ApiResponse response = movieReservationService.saveMovieReservation(requestDto); + + // 응답 코드 확인 + assertEquals(HttpStatusCode.CONFLICT, response.status()); + } +} diff --git a/cinema-application/src/testFixtures/java/com/hanghae/application/TestDataFactory.java b/cinema-application/src/testFixtures/java/com/hanghae/application/TestDataFactory.java new file mode 100644 index 000000000..4c6f2a328 --- /dev/null +++ b/cinema-application/src/testFixtures/java/com/hanghae/application/TestDataFactory.java @@ -0,0 +1,196 @@ +package com.hanghae.application; + +import com.hanghae.application.dto.request.MovieReservationRequestDto; +import com.hanghae.domain.model.*; +import com.hanghae.domain.model.enums.MovieGenre; +import com.hanghae.domain.model.enums.MovieRating; +import com.hanghae.domain.model.enums.ScreenSeat; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * java-test-fixtures - 테스트를 수행하기 위한 일관적이고 고정된 상태를 설정 + * 테스트시 사용하는 데이터들(영화정보, 회원정보 등)에 대한 Fixture의 재사용성을 높이기 위해 따로 분리 + */ +public class TestDataFactory { + + // 영화 예매 요청 DTO 생성 + public static MovieReservationRequestDto createMovieReservationRequestDto() { + return new MovieReservationRequestDto(1L, 1L, ScreenSeat.A01, 2); + } + + public static MovieReservationRequestDto createMovieReservationRequestDto(Long scheduleId, Long memberId, int seatCount, ScreenSeat seat) { + return new MovieReservationRequestDto(scheduleId, memberId, seat, seatCount); + } + + // 첨부파일 생성 + public static UploadFile createUploadFile() { + return new UploadFile( + 1L, + "/file/test", + "test.png", + "test.png", + 99L, + null, + null, + null + ); + } + + // 영화 생성 + public static Movie createMovie() { + return new Movie( + 1L, + "테스트 영화", + MovieRating.ALL, + LocalDate.of(2024, 1, 1), + 90L, + MovieGenre.ACTION, + createUploadFile(), + 99L, + null, + null, + null + ); + } + + // 상영관 생성 + public static Screen createScreen() { + return new Screen( + 1L, + "1관", + 99L, + null, + null, + null + ); + } + + public static Screen createScreen(Long screenId, String screenName) { + return new Screen( + screenId, + screenName, + 99L, + null, + null, + null + ); + } + + // 영화 상영 일정 생성 + public static ScreeningSchedule createScreeningSchedule() { + return new ScreeningSchedule( + 1L, + createMovie(), + createScreen(), + LocalDateTime.of(2024, 10, 15, 11, 11, 11), + 99L, + null, + null, + null + ); + } + + // 상영 시간표 생성 + public static ScreeningSchedule createScreeningSchedule(Long scheduleId, Screen screen) { + return new ScreeningSchedule( + scheduleId, + createMovie(), + screen, + LocalDateTime.of(2024, 10, 15, 11, 11, 11), + 99L, + null, + null, + null + ); + } + + // 회원 생성 + public static Member createMember() { + return new Member( + 99L, + LocalDate.of(1995, 1, 1), + 99L, + null, + null, + null + ); + } + + public static Member createMember(Long memberId, LocalDate birthDate) { + return new Member( + memberId, + birthDate, + memberId, + null, + null, + null + ); + } + + // 좌석 배치 정보 생성 + public static ScreenSeatLayout createScreenSeatLayout() { + return new ScreenSeatLayout( + 1L, + createScreen(), + "A", + 5L, + 99L, + null, + null, + null + ); + } + + public static ScreenSeatLayout createScreenSeatLayout(String seatRow, Long maxSeatNumber) { + return new ScreenSeatLayout( + 1L, + createScreen(), + seatRow, + maxSeatNumber, + 99L, + null, + null, + null + ); + } + + // 예매 정보 생성 + public static TicketReservation createTicketReservation() { + return new TicketReservation( + 1L, + ScreenSeat.A01, + createScreeningSchedule(), + createMember(), + 99L, + null, + null, + null + ); + } + + public static TicketReservation createTicketReservation(Long ticketId, ScreeningSchedule schedule, Member member, ScreenSeat seat) { + return new TicketReservation( + ticketId, + seat, + schedule, + member, + 99L, + null, + null, + null + ); + } + + public static List createTicketReservations() { + ScreeningSchedule screeningSchedule = createScreeningSchedule(); + Member member = createMember(); + + return List.of( + createTicketReservation(1L, screeningSchedule, member, ScreenSeat.A01), + createTicketReservation(2L, screeningSchedule, member, ScreenSeat.A02) + ); + } +} diff --git a/cinema-domain/build.gradle.kts b/cinema-domain/build.gradle.kts index dd17cc9aa..049f50318 100644 --- a/cinema-domain/build.gradle.kts +++ b/cinema-domain/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("java") id("org.springframework.boot") version "3.4.1" id("io.spring.dependency-management") version "1.1.7" + id("java-test-fixtures") // 테스트 픽스처 활성화 } group = "com.hanghae" diff --git a/cinema-infrastructure/build.gradle.kts b/cinema-infrastructure/build.gradle.kts index 4687e6ad7..806715771 100644 --- a/cinema-infrastructure/build.gradle.kts +++ b/cinema-infrastructure/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("java") id("org.springframework.boot") version "3.4.1" id("io.spring.dependency-management") version "1.1.7" + id("java-test-fixtures") // 테스트 픽스처 활성화 } group = "com.hanghae" diff --git a/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/MessageAdapter.java b/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/message/MessageAdapter.java similarity index 86% rename from cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/MessageAdapter.java rename to cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/message/MessageAdapter.java index fe6baa2f4..0064a55e9 100644 --- a/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/MessageAdapter.java +++ b/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/message/MessageAdapter.java @@ -1,6 +1,6 @@ -package com.hanghae.infrastructure.adapter; +package com.hanghae.infrastructure.adapter.message; -import com.hanghae.application.port.out.MessagePort; +import com.hanghae.application.port.out.message.MessagePort; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; diff --git a/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/redis/RedisRateLimitAdapter.java b/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/redis/RedisRateLimitAdapter.java new file mode 100644 index 000000000..a4a360482 --- /dev/null +++ b/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/redis/RedisRateLimitAdapter.java @@ -0,0 +1,109 @@ +package com.hanghae.infrastructure.adapter.redis; + +import com.hanghae.application.port.out.redis.RedisRateLimitPort; +import com.hanghae.infrastructure.lua.LuaScriptLoader; +import lombok.RequiredArgsConstructor; +import org.redisson.api.RScript; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; + +@Component +@RequiredArgsConstructor +public class RedisRateLimitAdapter implements RedisRateLimitPort { + private final RedisTemplate redisTemplate; + + /** + * 비정상적 사용 패턴을 감지하고 시스템을 보호하기 위해 + * 1분 내 50회 이상 요청 시 1시간 동안 해당 IP 를 차단 + */ + public boolean isAllowed(String ip) { + String luaScriptPath = "lua/searchRateLimit.lua"; + int maxRequest = 50; // 최대 요청 횟수 + int expireTime = 60; // 요청 카운트 만료 시간 (초) + int blockTime = 3600; // 차단 시간 (초) + + String requestKey = "rate_limit:" + ip; // 요청 카운트 키 + String blockKey = "blocked_ip:" + ip; // 차단된 IP 키 + + //lua script 가져오기 + String luaScript = LuaScriptLoader.loadScript(luaScriptPath); + + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setScriptText(luaScript); + script.setResultType(Long.class); + + Long result = redisTemplate.execute( + script, + Arrays.asList(requestKey, blockKey), // KEYS[1], KEYS[2] + String.valueOf(maxRequest), // ARGV[1] + String.valueOf(expireTime), // ARGV[2] + String.valueOf(blockTime) // ARGV[3] + ); + + return result != null && result == 1; + } + + /** + * 같은 시간대의 영화는 유저당 5분에 1번씩만 예매 가능 + * (체크와 동시에 키생성) + */ + @Override + public boolean tryReserveWithLimit(Long scheduleId, Long memberId) { + String luaScriptPath = "lua/reservationRateLimit.lua"; + int reservationCooldownSecound = 300; // 예약 제한 시간 (초) + String key = "reservation_limit:" + scheduleId + ":" + memberId; // 레디스 키 + + //lua script 가져오기 + String luaScript = LuaScriptLoader.loadScript(luaScriptPath); + + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setScriptText(luaScript); + script.setResultType(Long.class); + + Long result = redisTemplate.execute( + script, + Collections.singletonList(key), // KEYS[1] + String.valueOf(reservationCooldownSecound) // ARGV[1] + ); + + return result != null && result == 1; + + /* + //lua script 없이 아래 방법으로도 가능 + + Boolean isSet = redisTemplate.opsForValue().setIfAbsent( + key, "1", Duration.ofSeconds(reservationCooldownSecound)); + //해당 키가 이미 존재하면 아무것도 하지 않고 false를 반환 + + return Boolean.TRUE.equals(isSet); // 값이 설정되었다면 예약 가능 + */ + } + + /** + * 5분 제한 여부 확인 (제한된 경우 false 반환) + */ + @Override + public boolean canReserve(Long scheduleId, Long memberId) { + String key = getReservationKey(scheduleId, memberId); + return Boolean.FALSE.equals(redisTemplate.hasKey(key)); // 키가 없으면 예약 가능 + } + + /** + * 예매 성공 후 5분 제한 설정 + */ + @Override + public void setReservationLimit(Long scheduleId, Long memberId) { + String key = getReservationKey(scheduleId, memberId); + int reservationCooldownSecound = 300; // 예약 제한 시간 (초) + redisTemplate.opsForValue().set(key, "1", Duration.ofSeconds(reservationCooldownSecound)); + } + + private String getReservationKey(Long scheduleId, Long memberId) { + return "reservation_limit:" + scheduleId + ":" + memberId; + } +} diff --git a/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/RedissonLockAdapter.java b/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/redis/RedissonLockAdapter.java similarity index 96% rename from cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/RedissonLockAdapter.java rename to cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/redis/RedissonLockAdapter.java index 832080783..11666f144 100644 --- a/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/RedissonLockAdapter.java +++ b/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/redis/RedissonLockAdapter.java @@ -1,6 +1,6 @@ -package com.hanghae.infrastructure.adapter; +package com.hanghae.infrastructure.adapter.redis; -import com.hanghae.application.port.out.RedissonLockPort; +import com.hanghae.application.port.out.redis.RedissonLockPort; import com.hanghae.domain.model.enums.ScreenSeat; import lombok.RequiredArgsConstructor; import org.redisson.api.RLock; diff --git a/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/MemberRepositoryAdapter.java b/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/repository/MemberRepositoryAdapter.java similarity index 83% rename from cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/MemberRepositoryAdapter.java rename to cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/repository/MemberRepositoryAdapter.java index 1d3100bb5..176576dcd 100644 --- a/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/MemberRepositoryAdapter.java +++ b/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/repository/MemberRepositoryAdapter.java @@ -1,6 +1,6 @@ -package com.hanghae.infrastructure.adapter; +package com.hanghae.infrastructure.adapter.repository; -import com.hanghae.application.port.out.MemberRepositoryPort; +import com.hanghae.application.port.out.repository.MemberRepositoryPort; import com.hanghae.domain.model.Member; import com.hanghae.infrastructure.mapper.MemberMapper; import com.hanghae.infrastructure.repository.MemberRepositoryJpa; diff --git a/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/MovieRepositoryAdapter.java b/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/repository/MovieRepositoryAdapter.java similarity index 94% rename from cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/MovieRepositoryAdapter.java rename to cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/repository/MovieRepositoryAdapter.java index eb4e5b606..46648a9c2 100644 --- a/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/MovieRepositoryAdapter.java +++ b/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/repository/MovieRepositoryAdapter.java @@ -1,7 +1,7 @@ -package com.hanghae.infrastructure.adapter; +package com.hanghae.infrastructure.adapter.repository; -import com.hanghae.application.dto.MovieScheduleRequestDto; -import com.hanghae.application.port.out.MovieRepositoryPort; +import com.hanghae.application.dto.request.MovieScheduleRequestDto; +import com.hanghae.application.port.out.repository.MovieRepositoryPort; import com.hanghae.application.projection.MovieScheduleProjection; import com.hanghae.infrastructure.config.RedisCacheName; import com.hanghae.infrastructure.entity.*; diff --git a/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/ScreenSeatLayoutAdapter.java b/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/repository/ScreenSeatLayoutAdapter.java similarity index 84% rename from cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/ScreenSeatLayoutAdapter.java rename to cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/repository/ScreenSeatLayoutAdapter.java index a9994fa3f..a198171ec 100644 --- a/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/ScreenSeatLayoutAdapter.java +++ b/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/repository/ScreenSeatLayoutAdapter.java @@ -1,6 +1,6 @@ -package com.hanghae.infrastructure.adapter; +package com.hanghae.infrastructure.adapter.repository; -import com.hanghae.application.port.out.ScreenSeatLayoutRepositoryPort; +import com.hanghae.application.port.out.repository.ScreenSeatLayoutRepositoryPort; import com.hanghae.domain.model.ScreenSeatLayout; import com.hanghae.infrastructure.entity.ScreenSeatLayoutEntity; import com.hanghae.infrastructure.mapper.ScreenSeatLayoutMapper; @@ -8,9 +8,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import java.util.List; -import java.util.stream.Collectors; - @Component @RequiredArgsConstructor public class ScreenSeatLayoutAdapter implements ScreenSeatLayoutRepositoryPort { diff --git a/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/ScreeningScheduleRepositoryAdapter.java b/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/repository/ScreeningScheduleRepositoryAdapter.java similarity index 75% rename from cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/ScreeningScheduleRepositoryAdapter.java rename to cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/repository/ScreeningScheduleRepositoryAdapter.java index 0b7eccf88..4cd9cd578 100644 --- a/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/ScreeningScheduleRepositoryAdapter.java +++ b/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/repository/ScreeningScheduleRepositoryAdapter.java @@ -1,12 +1,8 @@ -package com.hanghae.infrastructure.adapter; +package com.hanghae.infrastructure.adapter.repository; -import com.hanghae.application.port.out.ScreeningScheduleRepositoryPort; -import com.hanghae.domain.model.Movie; -import com.hanghae.domain.model.Screen; +import com.hanghae.application.port.out.repository.ScreeningScheduleRepositoryPort; import com.hanghae.domain.model.ScreeningSchedule; -import com.hanghae.infrastructure.entity.ScreeningScheduleEntity; import com.hanghae.infrastructure.mapper.ScreeningScheduleMapper; -import com.hanghae.infrastructure.mapper.UploadFileMapper; import com.hanghae.infrastructure.repository.ScreeningScheduleRepositoryJpa; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; diff --git a/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/TicketReservationAdapter.java b/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/repository/TicketReservationAdapter.java similarity index 91% rename from cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/TicketReservationAdapter.java rename to cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/repository/TicketReservationAdapter.java index 55bfa8dc9..c9888c9f7 100644 --- a/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/TicketReservationAdapter.java +++ b/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/adapter/repository/TicketReservationAdapter.java @@ -1,7 +1,6 @@ -package com.hanghae.infrastructure.adapter; +package com.hanghae.infrastructure.adapter.repository; -import com.hanghae.application.port.out.TicketReservationRepositoryPort; -import com.hanghae.domain.model.ScreenSeatLayout; +import com.hanghae.application.port.out.repository.TicketReservationRepositoryPort; import com.hanghae.domain.model.TicketReservation; import com.hanghae.domain.model.enums.ScreenSeat; import com.hanghae.infrastructure.entity.TicketReservationEntity; diff --git a/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/lua/LuaScriptLoader.java b/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/lua/LuaScriptLoader.java new file mode 100644 index 000000000..e25f6fd64 --- /dev/null +++ b/cinema-infrastructure/src/main/java/com/hanghae/infrastructure/lua/LuaScriptLoader.java @@ -0,0 +1,23 @@ +package com.hanghae.infrastructure.lua; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.stream.Collectors; + +public class LuaScriptLoader { + public static String loadScript(String resourcePath) { + try (InputStream inputStream = LuaScriptLoader.class.getClassLoader().getResourceAsStream(resourcePath); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + + if (inputStream == null) { + throw new RuntimeException("Lua script not found: " + resourcePath); + } + + return reader.lines().collect(Collectors.joining("\n")); + } catch (Exception e) { + throw new RuntimeException("Failed to load Lua script: " + resourcePath, e); + } + } +} diff --git a/cinema-infrastructure/src/main/resources/lua/reservationRateLimit.lua b/cinema-infrastructure/src/main/resources/lua/reservationRateLimit.lua new file mode 100644 index 000000000..ec0407cb2 --- /dev/null +++ b/cinema-infrastructure/src/main/resources/lua/reservationRateLimit.lua @@ -0,0 +1,10 @@ +-- 예약 제한 (유저 기준) +local key = KEYS[1] -- 예약 키 (scheduleId + memberId) +local lockTime = tonumber(ARGV[1]) -- 예약 제한 시간 (초) + +-- 이미 예약된 경우 차단 +if redis.call("exists", key) == 1 then return 0 end + +-- 예약 가능 시 만료 시간 설정 +redis.call("setex", key, lockTime, "RESERVED") +return 1 \ No newline at end of file diff --git a/cinema-infrastructure/src/main/resources/lua/searchRateLimit.lua b/cinema-infrastructure/src/main/resources/lua/searchRateLimit.lua new file mode 100644 index 000000000..e9f81e7fc --- /dev/null +++ b/cinema-infrastructure/src/main/resources/lua/searchRateLimit.lua @@ -0,0 +1,27 @@ +--[[ redis 디버깅 로그 출력 +redis.log(redis.LOG_NOTICE, "maxRequests: " .. tostring(ARGV[1])) +redis.log(redis.LOG_NOTICE, "expireTime: " .. tostring(ARGV[2])) +redis.log(redis.LOG_NOTICE, "blockTime: " .. tostring(ARGV[3])) +]] + +--[[ 요청 제한 (IP 기반) ]] +local key = KEYS[1] --[[ 요청 카운트 키 ]] +local blockKey = KEYS[2] --[[ 차단된 IP 키 ]] +local maxRequests = tonumber(ARGV[1]) --[[ 최대 요청 횟수 (초) ]] +local expireTime = tonumber(ARGV[2]) --[[ 요청 카운트 만료 시간 (초) ]] +local blockTime = tonumber(ARGV[3]) --[[ 차단 시간 (초) ]] + +--[[ 차단 여부 확인 ]] +if redis.call("exists", blockKey) == 1 then return 0 end + +--[[ 요청 카운트 증가 및 만료 시간 설정 ]] +local count = redis.call("incr", key) +if count == 1 then redis.call("expire", key, expireTime) end + +--[[ 요청 제한 초과 시 차단 ]] +if count > maxRequests then + redis.call("setex", blockKey, blockTime, "BLOCKED") + return 0 +end + +return 1 \ No newline at end of file diff --git a/docs/img/jacoco_report_aggregation.png b/docs/img/jacoco_report_aggregation.png new file mode 100644 index 000000000..17f3ec758 Binary files /dev/null and b/docs/img/jacoco_report_aggregation.png differ diff --git a/docs/img/jacoco_test_report.png b/docs/img/jacoco_test_report.png new file mode 100644 index 000000000..4516a1c21 Binary files /dev/null and b/docs/img/jacoco_test_report.png differ