diff --git a/README.md b/README.md index 890d09835..83c83986f 100644 --- a/README.md +++ b/README.md @@ -400,4 +400,21 @@ export default function() { - 실패율은 0% 이다. ![img_20.png](img_20.png) -- 최대 TPS는 4.13K 이다. \ No newline at end of file +- 최대 TPS는 4.13K 이다. + +# 6. leaseTime, waitTime +``` +waitTime: 1초, leaseTime: 5초 +``` +**waitTime** +- 분산 캐싱 적용 후, 평균 처리 시간이 약 450ms가 소요되었으므로 넉넉하게 1초로 설정했습니다. + +**leaseTime** +- leaseTime은 가장 오래 걸린 처리 시간에 영향을 받으므로 waitTime보다 길게 5초로 설정했습니다. +너무 길지 않게 설정해서 다른 요청들이 불필요하게 대기하지 않도록 하였습니다. + + +# 7. 테스트 커버리지 결과 +![img_7.png](img_7.png) + +![img_10.png](img_10.png) diff --git a/build.gradle b/build.gradle index b94bbf574..c26451aaf 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.1.5' id 'io.spring.dependency-management' version '1.1.3' + id 'jacoco' } bootJar.enabled = false // 빌드시 현재 모듈(multi-module)의 .jar를 생성하지 않습니다. @@ -19,6 +20,7 @@ subprojects { // 모든 하위 모듈들에 이 설정을 적용합니다. apply plugin: 'java-library' apply plugin: 'org.springframework.boot' apply plugin: 'io.spring.dependency-management' + apply plugin: 'jacoco' configurations { compileOnly { @@ -72,7 +74,22 @@ subprojects { // 모든 하위 모듈들에 이 설정을 적용합니다. implementation("com.google.guava:guava:31.1-jre") } + jacoco { + toolVersion = "0.8.10" + } + + jacocoTestReport { + dependsOn test + + reports { + xml.required = false + csv.required = false + html.required = true // HTML 리포트 생성 + } + } + test { useJUnitPlatform() + finalizedBy jacocoTestReport } } \ No newline at end of file diff --git a/img_10.png b/img_10.png new file mode 100644 index 000000000..01206a142 Binary files /dev/null and b/img_10.png differ diff --git a/img_7.png b/img_7.png new file mode 100644 index 000000000..e354079d2 Binary files /dev/null and b/img_7.png differ diff --git a/module-api/build.gradle b/module-api/build.gradle index 94acdb2a3..de839b1b9 100644 --- a/module-api/build.gradle +++ b/module-api/build.gradle @@ -1,5 +1,6 @@ plugins { id 'java' + id 'jacoco' } bootJar.enabled = true @@ -13,4 +14,23 @@ dependencies { testImplementation platform('org.junit:junit-bom:5.9.1') testImplementation 'org.junit.jupiter:junit-jupiter' +} + +jacoco { + toolVersion = "0.8.10" +} + +jacocoTestReport { + dependsOn test + + reports { + xml.required = false + csv.required = false + html.required = true + } +} + +test { + useJUnitPlatform() + finalizedBy jacocoTestReport } \ No newline at end of file diff --git a/module-api/src/main/java/org/example/controller/ReservationController.java b/module-api/src/main/java/org/example/controller/ReservationController.java index 39fa6bfda..20df2b1d2 100644 --- a/module-api/src/main/java/org/example/controller/ReservationController.java +++ b/module-api/src/main/java/org/example/controller/ReservationController.java @@ -4,10 +4,8 @@ import org.example.baseresponse.BaseResponse; import org.example.baseresponse.BaseResponseStatus; import org.example.dto.request.ReservationRequestDto; -import org.example.service.ReservationService; -import org.springframework.http.ResponseEntity; +import org.example.service.reservation.ReservationService; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; diff --git a/module-api/src/main/java/org/example/dto/request/MoviesFilterRequestDto.java b/module-api/src/main/java/org/example/dto/request/MoviesFilterRequestDto.java index 5adf7c429..dc7de2c4f 100644 --- a/module-api/src/main/java/org/example/dto/request/MoviesFilterRequestDto.java +++ b/module-api/src/main/java/org/example/dto/request/MoviesFilterRequestDto.java @@ -1,6 +1,7 @@ package org.example.dto.request; import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Setter; import org.example.annotaion.ValidEnum; @@ -8,12 +9,11 @@ @Setter @Getter +@AllArgsConstructor public class MoviesFilterRequestDto { @Size(max = 255) private String movieTitle; @ValidEnum(enumClass = Genre.class, message = "장르는 다음 중 하나여야 합니다: ACTION, ROMANCE, HORROR, SF") private String genre; - - private boolean playing; } diff --git a/module-api/src/main/java/org/example/dto/request/ReservationSeatDto.java b/module-api/src/main/java/org/example/dto/request/ReservationSeatDto.java index f81f24cc3..db4bebfbe 100644 --- a/module-api/src/main/java/org/example/dto/request/ReservationSeatDto.java +++ b/module-api/src/main/java/org/example/dto/request/ReservationSeatDto.java @@ -1,12 +1,15 @@ package org.example.dto.request; +import jakarta.validation.constraints.NotNull; import org.example.annotaion.ValidEnum; import org.example.domain.seat.Col; import org.example.domain.seat.Row; public record ReservationSeatDto( + @NotNull @ValidEnum(enumClass = Row.class, message = "올바른 행 이름을 입력해주세요.") String row, + @NotNull @ValidEnum(enumClass = Col.class, message = "올바른 열 이름을 입력해주세요.") String col ) {} diff --git a/module-api/src/main/java/org/example/service/movie/FindMovieService.java b/module-api/src/main/java/org/example/service/movie/FindMovieService.java index 235266cb2..fe5416780 100644 --- a/module-api/src/main/java/org/example/service/movie/FindMovieService.java +++ b/module-api/src/main/java/org/example/service/movie/FindMovieService.java @@ -19,8 +19,7 @@ public class FindMovieService { private final MovieJpaRepository movieJpaRepository; @Cacheable(value = "playingMovies", - key = "(#moviesFilterRequestDto.genre != null ? #moviesFilterRequestDto.genre : 'ALL') " + - "+ (#moviesFilterRequestDto.playing != null ? #moviesFilterRequestDto.playing : false)") + key = "(#moviesFilterRequestDto.genre != null ? #moviesFilterRequestDto.genre : 'ALL') + true") public FoundMovieScreeningInfoList getPlayingMovies(MoviesFilterRequestDto moviesFilterRequestDto) { Genre genre = null; if (moviesFilterRequestDto.getGenre() != null) { @@ -28,7 +27,7 @@ public FoundMovieScreeningInfoList getPlayingMovies(MoviesFilterRequestDto movie } List movieScreeningInfos - = movieJpaRepository.findScreeningInfos(moviesFilterRequestDto.getMovieTitle(), genre, moviesFilterRequestDto.isPlaying()); + = movieJpaRepository.findScreeningInfos(moviesFilterRequestDto.getMovieTitle(), genre); return new FoundMovieScreeningInfoList(movieScreeningInfos); } } diff --git a/module-api/src/main/java/org/example/service/movie/MovieService.java b/module-api/src/main/java/org/example/service/movie/MovieService.java index 45b9c8dd2..b0d595eb4 100644 --- a/module-api/src/main/java/org/example/service/movie/MovieService.java +++ b/module-api/src/main/java/org/example/service/movie/MovieService.java @@ -8,13 +8,10 @@ import org.example.dto.response.PlayingMoviesResponseDto; import org.example.dto.response.ScreeningInfo; import org.example.dto.response.ScreeningTimeInfo; -import org.example.exception.RateLimitExceededException; import org.springframework.stereotype.Service; import java.util.*; -import static org.example.baseresponse.BaseResponseStatus.TOO_MANY_REQUEST_ERROR; - @Slf4j @Service @RequiredArgsConstructor diff --git a/module-api/src/main/java/org/example/service/ReservationService.java b/module-api/src/main/java/org/example/service/reservation/ReservationService.java similarity index 56% rename from module-api/src/main/java/org/example/service/ReservationService.java rename to module-api/src/main/java/org/example/service/reservation/ReservationService.java index e372f981e..eadf18b96 100644 --- a/module-api/src/main/java/org/example/service/ReservationService.java +++ b/module-api/src/main/java/org/example/service/reservation/ReservationService.java @@ -1,10 +1,8 @@ -package org.example.service; +package org.example.service.reservation; -import jakarta.transaction.Transactional; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.example.config.RedissonLockUtil; -import org.example.domain.reservation.Reservation; import org.example.domain.reservationseat.ReservationSeat; import org.example.domain.seat.Col; import org.example.domain.seat.Row; @@ -12,7 +10,6 @@ import org.example.dto.SeatsDto; import org.example.dto.request.ReservationRequestDto; import org.example.exception.SeatException; -import org.example.repository.ReservationJpaRepository; import org.example.repository.ReservationSeatRepository; import org.example.repository.ScreenScheduleJpaRepository; import org.example.repository.SeatJpaRepository; @@ -21,18 +18,17 @@ import java.util.ArrayList; import java.util.List; -import static org.example.baseresponse.BaseResponseStatus.ALREADY_RESERVED_SEAT_ERROR; import static org.example.baseresponse.BaseResponseStatus.UNAVAILABLE_SEAT_ERROR; @Slf4j @Service @AllArgsConstructor public class ReservationService { - private final ReservationJpaRepository reservationJpaRepository; - private final SeatJpaRepository seatJpaRepository; - private final ScreenScheduleJpaRepository screenScheduleJpaRepository; private final ReservationSeatRepository reservationSeatRepository; + private final ScreenScheduleJpaRepository screenScheduleJpaRepository; private final RedissonLockUtil redissonLockUtil; + private final SaveReservationService saveReservationService; + private final SeatJpaRepository seatJpaRepository; public void reserveMovie(ReservationRequestDto reservationRequestDto) { List reservationSeats = new ArrayList<>( @@ -49,37 +45,34 @@ public void reserveMovie(ReservationRequestDto reservationRequestDto) { // 사용자가 동일한 상영에 대해 예약한 좌석 검증 validateUserReserveSeats(reservationSeats, reservationRequestDto.usersId(), reservationRequestDto.screenScheduleId()); + // 좌석들 반환 + List seats = validateReservedSeats(screenRoomId, reservationSeats); + // 개별 좌석별 락 키 생성 List lockKeys = reservationRequestDto.reservationSeats().stream() .map(seat -> "lock:seat:" + reservationRequestDto.screenScheduleId() + ":" + seat.row() + ":" + seat.col()) .toList(); // Redisson MultiLock 적용 (여러 개의 좌석을 동시에 보호) - redissonLockUtil.executeWithMultiLock(lockKeys, 5, 10, () -> { - saveReservationWithTransaction(reservationRequestDto, reservationSeats, screenRoomId); + redissonLockUtil.executeWithMultiLock(lockKeys, 1, 5, () -> { + saveReservationService.saveReservationWithTransaction(reservationRequestDto.usersId(), reservationRequestDto.screenScheduleId(), seats); return null; }); } - @Transactional - public void saveReservationWithTransaction(ReservationRequestDto reservationRequestDto, List reservationSeats, Long screenRoomId) { - // 좌석에 락을 걸고 저장할 좌석들 반환 - List seats = validateReservedSeats(screenRoomId, reservationSeats); + public List validateReservedSeats(Long screenRoomId, List reservationSeats) { + List seats = new ArrayList<>(); + for (SeatsDto reservationSeat : reservationSeats) { + Seat seat = seatJpaRepository.findSeats(screenRoomId, reservationSeat.getRow(), reservationSeat.getCol()) + .orElseThrow(() -> new SeatException(UNAVAILABLE_SEAT_ERROR)); - // 예약된 좌석인지 검증 - for (Seat seat : seats) { - boolean isReserved = reservationSeatRepository.findReservedSeatBySeatId(reservationRequestDto.screenScheduleId(), seat.getId()).isPresent(); - if (isReserved) { - throw new SeatException(ALREADY_RESERVED_SEAT_ERROR); - } + seats.add(seat); } - - Long reservationId = saveReservation(reservationRequestDto.usersId(), reservationRequestDto.screenScheduleId()); - saveReservationSeats(seats, reservationId); + return seats; } private void validateSeats(List seats) { - Seat.validateCountExceeded(seats.size()); + Seat.validateSeatCount(seats.size()); Seat.validateContinuousSeats(seats); } @@ -89,33 +82,9 @@ private void validateUserReserveSeats(List reservationSeats, Long user return; } - Seat.validateCountExceeded(reservationSeats.size() + reservedSeats.size()); // 예약하려는 좌석이 5개 이상인지 - Seat.containsReservedSeat(reservationSeats, reservedSeats); // 이미 예약된 좌석과 겹치는지 - Seat.isSameRow(reservationSeats, reservedSeats); // 좌석이 같은 행에 있는지 - Seat.isContinuousCol(reservationSeats, reservedSeats); // 좌석이 연속된 열인지 - } - - private List validateReservedSeats(Long screenRoomId, List reservationSeats) { - List seats = new ArrayList<>(); - for (SeatsDto reservationSeat : reservationSeats) { - Seat seat = seatJpaRepository.findSeats(screenRoomId, reservationSeat.getRow(), reservationSeat.getCol()) - .orElseThrow(() -> new SeatException(UNAVAILABLE_SEAT_ERROR)); - - seats.add(seat); - } - return seats; - } - - private Long saveReservation(Long userId, Long screenScheduleId) { - Reservation reservation = Reservation.of(userId, screenScheduleId); - Reservation savedReservation = reservationJpaRepository.save(reservation); - return savedReservation.getId(); - } - - private void saveReservationSeats(List seats, Long reservationId) { - for (Seat seat : seats) { - ReservationSeat reservationSeat = ReservationSeat.of(reservationId, seat.getId()); - reservationSeatRepository.save(reservationSeat); - } + ReservationSeat.validateCountExceeded(reservationSeats, reservedSeats); // 예약하려는 좌석이 5개 이상인지 + ReservationSeat.containsReservedSeat(reservationSeats, reservedSeats); // 이미 예약된 좌석과 겹치는지 + ReservationSeat.isSameRow(reservationSeats, reservedSeats); // 좌석이 같은 행에 있는지 + ReservationSeat.isContinuousCol(reservationSeats, reservedSeats); // 좌석이 연속된 열인지 } } \ No newline at end of file diff --git a/module-api/src/main/java/org/example/service/reservation/SaveReservationService.java b/module-api/src/main/java/org/example/service/reservation/SaveReservationService.java new file mode 100644 index 000000000..a08f4acbe --- /dev/null +++ b/module-api/src/main/java/org/example/service/reservation/SaveReservationService.java @@ -0,0 +1,53 @@ +package org.example.service.reservation; + +import jakarta.transaction.Transactional; +import lombok.AllArgsConstructor; +import org.example.domain.reservation.Reservation; +import org.example.domain.reservationseat.ReservationSeat; +import org.example.domain.seat.Seat; +import org.example.dto.SeatsDto; +import org.example.dto.request.ReservationRequestDto; +import org.example.exception.SeatException; +import org.example.repository.ReservationJpaRepository; +import org.example.repository.ReservationSeatRepository; +import org.example.repository.SeatJpaRepository; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +import static org.example.baseresponse.BaseResponseStatus.*; + +@Service +@AllArgsConstructor +public class SaveReservationService { + private final ReservationJpaRepository reservationJpaRepository; + private final ReservationSeatRepository reservationSeatRepository; + + @Transactional + public void saveReservationWithTransaction(Long userId, Long screenScheduleId, List seats) { + // 예약된 좌석인지 검증 + for (Seat seat : seats) { + boolean isReserved = reservationSeatRepository.findReservedSeatBySeatId(screenScheduleId, seat.getId()).isPresent(); + if (isReserved) { + throw new SeatException(CONCURRENT_RESERVATION_ERROR); + } + } + + Long reservationId = saveReservation(userId, screenScheduleId); + saveReservationSeats(seats, reservationId); + } + + private Long saveReservation(Long userId, Long screenScheduleId) { + Reservation reservation = Reservation.of(userId, screenScheduleId); + Reservation savedReservation = reservationJpaRepository.save(reservation); + return savedReservation.getId(); + } + + private void saveReservationSeats(List seats, Long reservationId) { + for (Seat seat : seats) { + ReservationSeat reservationSeat = ReservationSeat.of(reservationId, seat.getId()); + reservationSeatRepository.save(reservationSeat); + } + } +} diff --git a/module-api/src/test/java/org/example/ApiApplicationTest.java b/module-api/src/test/java/org/example/ApiApplicationTest.java index d213b6416..7cf2f49fd 100644 --- a/module-api/src/test/java/org/example/ApiApplicationTest.java +++ b/module-api/src/test/java/org/example/ApiApplicationTest.java @@ -1,110 +1,14 @@ package org.example; import org.junit.jupiter.api.Test; -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.boot.test.web.client.TestRestTemplate; -import org.springframework.boot.test.web.server.LocalServerPort; -import org.springframework.http.*; +import org.springframework.test.context.ActiveProfiles; -import static org.junit.jupiter.api.Assertions.assertEquals; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@AutoConfigureMockMvc +@SpringBootTest +@ActiveProfiles("test") class ApiApplicationTest { - @LocalServerPort - private int port; - - @Autowired - private TestRestTemplate restTemplate; - - private static final String TEST_IP = "127.0.0.1"; - - @Test - void testGuavaRateLimiter_BlocksExcessRequests() throws Exception { - String url = "http://localhost:" + port + "/movies/playing"; - - // 첫 번째 요청 - ResponseEntity response1 = restTemplate.getForEntity(url, String.class); - assertEquals(HttpStatus.OK, response1.getStatusCode()); - - // 두 번째 요청 - ResponseEntity response2 = restTemplate.getForEntity(url, String.class); - assertEquals(HttpStatus.OK, response2.getStatusCode()); - - // 세 번째 요청 - ResponseEntity response3 = restTemplate.getForEntity(url, String.class); - assertEquals(HttpStatus.TOO_MANY_REQUESTS, response3.getStatusCode()); - } - - @Test - void testRedisRateLimit_UnderLimit() { - String url = "http://localhost:" + port + "/movies/playing"; - HttpHeaders headers = new HttpHeaders(); - headers.set("X-Forwarded-For", TEST_IP); - - for (int i = 0; i < 50; i++) { - ResponseEntity response = restTemplate.exchange( - url, - HttpMethod.GET, - new HttpEntity<>(headers), - String.class - ); - assertEquals(HttpStatus.OK, response.getStatusCode()); - } - } - - @Test - void testRateLimit_ExceedLimit() { - String url = "http://localhost:" + port + "/movies/playing"; - HttpHeaders headers = new HttpHeaders(); - headers.set("X-Forwarded-For", TEST_IP); - - // 49번 요청 실행 - for (int i = 0; i < 50; i++) { - restTemplate.exchange( - url, - HttpMethod.GET, - new HttpEntity<>(headers), - String.class - ); - } - - // 50번째 요청은 차단되어야 함 - ResponseEntity blockedResponse = restTemplate.exchange( - url, - HttpMethod.GET, - new HttpEntity<>(headers), - String.class - ); - assertEquals(HttpStatus.TOO_MANY_REQUESTS, blockedResponse.getStatusCode()); - } @Test - void testRateLimit_Unblock() throws InterruptedException { - String url = "http://localhost:" + port + "/movies/playing"; - HttpHeaders headers = new HttpHeaders(); - headers.set("X-Forwarded-For", TEST_IP); - - // 제한 초과 - for (int i = 0; i < 50; i++) { - restTemplate.exchange( - url, - HttpMethod.GET, - new HttpEntity<>(headers), - String.class - ); - } - - // 테스트를 위해 block이 해제되는 시간을 1분 후로 설정 - Thread.sleep(60 * 1000); - ResponseEntity responseAfterOneMin = restTemplate.exchange( - url, - HttpMethod.GET, - new HttpEntity<>(headers), - String.class - ); - assertEquals(HttpStatus.OK, responseAfterOneMin.getStatusCode()); + void contextLoads() { } } \ No newline at end of file diff --git a/module-api/src/test/java/org/example/RateLimiterTest.java b/module-api/src/test/java/org/example/RateLimiterTest.java new file mode 100644 index 000000000..bd597fa5c --- /dev/null +++ b/module-api/src/test/java/org/example/RateLimiterTest.java @@ -0,0 +1,122 @@ +package org.example; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.*; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class RateLimiterTest { + @LocalServerPort + private int port; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private StringRedisTemplate redisTemplate; + + private static final String TEST_IP = "127.0.0.1"; + + @AfterEach + void cleanup() { + redisTemplate.delete("request:" + TEST_IP); + redisTemplate.delete("block:" + TEST_IP); + } + + @Test + void testGuavaRateLimiter_BlocksExcessRequests() throws Exception { + String url = "http://localhost:" + port + "/movies/playing"; + + // 첫 번째 요청 + ResponseEntity response1 = restTemplate.getForEntity(url, String.class); + assertEquals(HttpStatus.OK, response1.getStatusCode()); + + // 두 번째 요청 + ResponseEntity response2 = restTemplate.getForEntity(url, String.class); + assertEquals(HttpStatus.OK, response2.getStatusCode()); + + // 세 번째 요청 + ResponseEntity response3 = restTemplate.getForEntity(url, String.class); + assertEquals(HttpStatus.TOO_MANY_REQUESTS, response3.getStatusCode()); + } + + @Test + void testRedisRateLimit_UnderLimit() { + String url = "http://localhost:" + port + "/movies/playing"; + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Forwarded-For", TEST_IP); + + for (int i = 0; i < 50; i++) { + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + new HttpEntity<>(headers), + String.class + ); + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + } + + @Test + void testRedisRateLimit_ExceedLimit() { + String url = "http://localhost:" + port + "/movies/playing"; + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Forwarded-For", TEST_IP); + + // 49번 요청 실행 + for (int i = 0; i < 50; i++) { + restTemplate.exchange( + url, + HttpMethod.GET, + new HttpEntity<>(headers), + String.class + ); + } + + // 50번째 요청은 차단되어야 함 + ResponseEntity blockedResponse = restTemplate.exchange( + url, + HttpMethod.GET, + new HttpEntity<>(headers), + String.class + ); + assertEquals(HttpStatus.TOO_MANY_REQUESTS, blockedResponse.getStatusCode()); + } + + @Test + void testRedisRateLimit_Unblock() throws InterruptedException { + String url = "http://localhost:" + port + "/movies/playing"; + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Forwarded-For", TEST_IP); + + // 제한 초과 + for (int i = 0; i < 50; i++) { + restTemplate.exchange( + url, + HttpMethod.GET, + new HttpEntity<>(headers), + String.class + ); + } + + // 테스트를 위해 block이 해제되는 시간을 1분 후로 설정 + Thread.sleep(60 * 1000); + ResponseEntity responseAfterOneMin = restTemplate.exchange( + url, + HttpMethod.GET, + new HttpEntity<>(headers), + String.class + ); + assertEquals(HttpStatus.OK, responseAfterOneMin.getStatusCode()); + } +} \ No newline at end of file diff --git a/module-api/src/test/java/org/example/ReservationConcurrencyTest.java b/module-api/src/test/java/org/example/ReservationConcurrencyTest.java index fe2aff7fc..194a98271 100644 --- a/module-api/src/test/java/org/example/ReservationConcurrencyTest.java +++ b/module-api/src/test/java/org/example/ReservationConcurrencyTest.java @@ -1,16 +1,14 @@ package org.example; import org.assertj.core.api.Assertions; -import org.example.dto.SeatsDto; import org.example.dto.request.ReservationRequestDto; import org.example.dto.request.ReservationSeatDto; -import org.example.repository.ReservationJpaRepository; import org.example.repository.ReservationSeatRepository; -import org.example.service.ReservationService; +import org.example.service.reservation.ReservationService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.test.context.ActiveProfiles; import java.util.ArrayList; import java.util.List; @@ -19,7 +17,7 @@ import java.util.concurrent.Executors; @SpringBootTest -@Transactional +@ActiveProfiles("test") public class ReservationConcurrencyTest { @Autowired private ReservationService reservationService; @@ -35,12 +33,21 @@ void testConcurrentReservation() throws InterruptedException { List reservationSeatDtos = new ArrayList<>(); reservationSeatDtos.add(new ReservationSeatDto("ROW_A", "COL_1")); + reservationSeatDtos.add(new ReservationSeatDto("ROW_A", "COL_2")); + + List reservationSeatDtos2 = new ArrayList<>(); + reservationSeatDtos2.add(new ReservationSeatDto("ROW_A", "COL_2")); + reservationSeatDtos2.add(new ReservationSeatDto("ROW_A", "COL_3")); + for (long i = 0; i < numberOfThreads; i++) { ReservationRequestDto reservationRequestDto = new ReservationRequestDto(i, 2L, reservationSeatDtos); + ReservationRequestDto reservationRequestDto2 = new ReservationRequestDto(i+100, 2L, reservationSeatDtos2); + executorService.execute(() -> { try { reservationService.reserveMovie(reservationRequestDto); + reservationService.reserveMovie(reservationRequestDto2); } catch (Exception e) { System.out.println(e.getMessage()); } finally { @@ -53,6 +60,6 @@ void testConcurrentReservation() throws InterruptedException { executorService.shutdown(); List reservedSeats = reservationSeatRepository.findReservedSeatByScreenScheduleId(2L); - Assertions.assertThat(reservedSeats.size()).isEqualTo(1); + Assertions.assertThat(reservedSeats.size()).isEqualTo(2); } } diff --git a/module-api/src/test/java/org/example/controller/MovieControllerTest.java b/module-api/src/test/java/org/example/controller/MovieControllerTest.java index ec24a21fd..109036d64 100644 --- a/module-api/src/test/java/org/example/controller/MovieControllerTest.java +++ b/module-api/src/test/java/org/example/controller/MovieControllerTest.java @@ -1,29 +1,45 @@ package org.example.controller; -import org.example.service.movie.MovieService; +import org.example.baseresponse.BaseResponse; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; -import org.springframework.test.web.servlet.result.MockMvcResultHandlers; -import org.springframework.test.web.servlet.result.MockMvcResultMatchers; - -@WebMvcTest(controllers = MovieController.class) +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.*; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") class MovieControllerTest { + @LocalServerPort + private int port; + @Autowired - private MockMvc mockMvc; + private TestRestTemplate restTemplate; - @MockBean - private MovieService movieService; + private static final String TEST_IP = "127.0.0.1"; - @DisplayName("상영 중인 영화를 조회한다.") @Test - void getPlayingMovies() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.get("/movies/playing")) - .andDo(MockMvcResultHandlers.print()) - .andExpect(MockMvcResultMatchers.status().isOk()); + @DisplayName("상영 중인 영화 리스트를 조회한다.") + void searchPlayingMovies_Success() { + String url = "http://localhost:" + port + "/movies/playing?movieTitle=Inception&genre=SF"; + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Forwarded-For", TEST_IP); + + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + new HttpEntity<>(headers), + BaseResponse.class + ); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); } } \ No newline at end of file diff --git a/module-api/src/test/java/org/example/controller/ReservationControllerTest.java b/module-api/src/test/java/org/example/controller/ReservationControllerTest.java new file mode 100644 index 000000000..b0a64e1eb --- /dev/null +++ b/module-api/src/test/java/org/example/controller/ReservationControllerTest.java @@ -0,0 +1,425 @@ +package org.example.controller; + +import org.example.baseresponse.BaseResponse; +import org.example.baseresponse.error.BaseErrorResponse; +import org.example.dto.request.ReservationRequestDto; +import org.example.dto.request.ReservationSeatDto; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class ReservationControllerTest { + @LocalServerPort + private int port; + + @Autowired + private TestRestTemplate restTemplate; + + @DisplayName("예매 성공 테스트") + @Nested + class reserveMovie_Success { + @Test + @DisplayName("좌석 1개 예매할 때 예매에 성공한다.") + void reserveMovie_Success_1seat() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 1L, 1L, List.of(new ReservationSeatDto("ROW_A", "COL_1")) + ); + + ResponseEntity response = restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(1000, Objects.requireNonNull(response.getBody()).getCode()); + } + + @Test + @DisplayName("좌석 5개 예매할 때 예매에 성공한다.") + void reserveMovie_Success_5seat() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 2L, + 1L, + List.of(new ReservationSeatDto("ROW_B", "COL_1"), + new ReservationSeatDto("ROW_B", "COL_2"), + new ReservationSeatDto("ROW_B", "COL_3"), + new ReservationSeatDto("ROW_B", "COL_4"), + new ReservationSeatDto("ROW_B", "COL_5")) + ); + + ResponseEntity response = restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(1000, Objects.requireNonNull(response.getBody()).getCode()); + } + } + + @DisplayName("입력이 올바르지 않을 때 예매 실패 테스트") + @Nested + class reserveMovie_Fail_Null { + @Test + @DisplayName("userId가 null일 을 예매 실패") + void reserveMovie_Fail_Null_UserId() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + null, 2L, List.of(new ReservationSeatDto("ROW_A", "COL_1")) + ); + + ResponseEntity response = restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + @DisplayName("screenScheduleId가 null일 때 예매 실패") + void reserveMovie_Fail_Null_ScreenScheduleId() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 3L, null, List.of(new ReservationSeatDto("ROW_A", "COL_1")) + ); + + ResponseEntity response = restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + @DisplayName("좌석이 null일 때 예매 실패") + void reserveMovie_Fail_Null_Seats() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 3L, 3L, null + ); + + ResponseEntity response = restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + } + + @DisplayName("유효하지 않은 좌석 번호일 때 예매 실패 테스트") + @Nested + class reserveMovie_Fail_Seat { + @Test + @DisplayName("유효하지 않은 좌석 번호일 때 예매 실패") + void reserveMovie_Fail_InvalidSeat() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 4L, 4L, List.of(new ReservationSeatDto("A", "1")) + ); + + ResponseEntity response = restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + @DisplayName("유효하지 않은 열 번호로 예매 실패") + void reserveMovie_Fail_InvalidCol() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 4L, 4L, List.of(new ReservationSeatDto("ROW_A", "COL_6")) + ); + + ResponseEntity response = restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + @DisplayName("유효하지 않은 행 번호로 예매 실패") + void reserveMovie_Fail_InvalidRow() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 4L, 4L, List.of(new ReservationSeatDto("ROW_F", "COL_1")) + ); + + ResponseEntity response = restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + } + + @DisplayName("예매하려는 좌석이 올바르지 않을 때 예매 실패 테스트") + @Nested + class reserveMovie_Fail_SeatCount { + @Test + @DisplayName("예매하려는 좌석 개수가 0개 때 예매 실패") + void reserveMovie_Fail_EmptySeats() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 5L, + 5L, + List.of() + ); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(requestDto), + new ParameterizedTypeReference() {} + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals("예약 가능한 좌석 개수가 아닙니다.", response.getBody().getMessage()); + } + + @Test + @DisplayName("예매 가능한 좌석 개수를 초과했을 때 예매 실패") + void reserveMovie_Fail_ExceedSeatCount() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 5L, + 5L, + List.of(new ReservationSeatDto("ROW_A", "COL_1"), + new ReservationSeatDto("ROW_A", "COL_2"), + new ReservationSeatDto("ROW_A", "COL_3"), + new ReservationSeatDto("ROW_A", "COL_4"), + new ReservationSeatDto("ROW_A", "COL_5"), + new ReservationSeatDto("ROW_B", "COL_1")) + ); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(requestDto), + new ParameterizedTypeReference() {} + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals("예약 가능한 좌석 개수가 아닙니다.", response.getBody().getMessage()); + } + + @Test + @DisplayName("좌석이 같은 행이 아니면 예매 실패") + void reserveMovie_Fail_SameRow() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 6L, + 6L, + List.of(new ReservationSeatDto("ROW_A", "COL_1"), + new ReservationSeatDto("ROW_B", "COL_2"), + new ReservationSeatDto("ROW_C", "COL_3")) + ); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(requestDto), + new ParameterizedTypeReference() {} + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals("연속된 좌석만 예약할 수 있습니다. 행이 다릅니다.", response.getBody().getMessage()); + } + + @Test + @DisplayName("좌석이 연속된 열이 아니면 예매 실패") + void reserveMovie_Fail_ContinuousCol() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 6L, + 6L, + List.of(new ReservationSeatDto("ROW_A", "COL_1"), + new ReservationSeatDto("ROW_A", "COL_3"), + new ReservationSeatDto("ROW_A", "COL_4")) + ); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(requestDto), + new ParameterizedTypeReference() {} + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals("연속된 좌석만 예약할 수 있습니다. 열이 연속되지 않았습니다.", response.getBody().getMessage()); + } + } + + @DisplayName("같은 사용자 좌석 예매 실패 테스트") + @Nested + class reserveMovie_Fail_SameUser { + @Test + @DisplayName("같은 사용자가 5자리 이상 예매 시도할 경우 예매 실패") + void reserveMovie_Fail_SameUser_ExceedSeatCount() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 7L, + 7L, + List.of(new ReservationSeatDto("ROW_A", "COL_1"), + new ReservationSeatDto("ROW_A", "COL_2"), + new ReservationSeatDto("ROW_A", "COL_3")) + ); + + ReservationRequestDto requestDto2 = new ReservationRequestDto( + 7L, + 7L, + List.of(new ReservationSeatDto("ROW_B", "COL_1"), + new ReservationSeatDto("ROW_B", "COL_2"), + new ReservationSeatDto("ROW_B", "COL_3")) + ); + + restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + ResponseEntity response2 = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(requestDto2), + new ParameterizedTypeReference() {} + ); + + assertEquals(HttpStatus.BAD_REQUEST, response2.getStatusCode()); + assertEquals("최대 예약 가능한 좌석을 초과했습니다.", response2.getBody().getMessage()); + } + + @Test + @DisplayName("같은 사용자가 다른 행의 좌석을 예매 시도할 경우 예매 실패") + void reserveMovie_Fail_SameUser_IsNotSameRow() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 8L, + 8L, + List.of(new ReservationSeatDto("ROW_A", "COL_1"), + new ReservationSeatDto("ROW_A", "COL_2")) + ); + + ReservationRequestDto requestDto2 = new ReservationRequestDto( + 8L, + 8L, + List.of(new ReservationSeatDto("ROW_B", "COL_1"), + new ReservationSeatDto("ROW_B", "COL_2")) + ); + + restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + ResponseEntity response2 = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(requestDto2), + new ParameterizedTypeReference() {} + ); + + assertEquals(HttpStatus.BAD_REQUEST, response2.getStatusCode()); + assertEquals("연속된 좌석만 예약할 수 있습니다. 행이 다릅니다.", response2.getBody().getMessage()); + } + + @Test + @DisplayName("같은 사용자가 연속되지 않는 열의 좌석을 예매 시도할 경우 예매 실패") + void reserveMovie_Fail_SameUser_IsNotContinuousCol() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 9L, + 9L, + List.of(new ReservationSeatDto("ROW_A", "COL_1"), + new ReservationSeatDto("ROW_A", "COL_2")) + ); + + ReservationRequestDto requestDto2 = new ReservationRequestDto( + 9L, + 9L, + List.of(new ReservationSeatDto("ROW_A", "COL_4"), + new ReservationSeatDto("ROW_A", "COL_5")) + ); + + restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + ResponseEntity response2 = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(requestDto2), + new ParameterizedTypeReference() {} + ); + + assertEquals(HttpStatus.BAD_REQUEST, response2.getStatusCode()); + assertEquals("연속된 좌석만 예약할 수 있습니다. 열이 연속되지 않았습니다.", response2.getBody().getMessage()); + } + + @Test + @DisplayName("같은 사용자가 이미 예매한 좌석을 예매 시도할 경우 예매 실패") + void reserveMovie_Fail_SameUser_AlreadyReserved() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 10L, + 10L, + List.of(new ReservationSeatDto("ROW_A", "COL_1"), + new ReservationSeatDto("ROW_A", "COL_2")) + ); + + ReservationRequestDto requestDto2 = new ReservationRequestDto( + 10L, + 10L, + List.of(new ReservationSeatDto("ROW_A", "COL_1"), + new ReservationSeatDto("ROW_A", "COL_2")) + ); + + restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + ResponseEntity response2 = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(requestDto2), + new ParameterizedTypeReference() {} + ); + + assertEquals(HttpStatus.BAD_REQUEST, response2.getStatusCode()); + assertEquals("이미 예약된 좌석입니다.", response2.getBody().getMessage()); + } + } +} \ No newline at end of file diff --git a/module-api/src/test/java/org/example/GuavaRateLimiterTest.java b/module-api/src/test/java/org/example/service/RateLimiterServiceTest.java similarity index 92% rename from module-api/src/test/java/org/example/GuavaRateLimiterTest.java rename to module-api/src/test/java/org/example/service/RateLimiterServiceTest.java index ee436883b..50de5a615 100644 --- a/module-api/src/test/java/org/example/GuavaRateLimiterTest.java +++ b/module-api/src/test/java/org/example/service/RateLimiterServiceTest.java @@ -1,4 +1,4 @@ -package org.example; +package org.example.service; import com.google.common.util.concurrent.RateLimiter; import org.example.service.movie.FindMovieService; @@ -7,11 +7,13 @@ import org.junit.jupiter.api.Test; import org.mockito.MockitoAnnotations; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -public class GuavaRateLimiterTest { +@ActiveProfiles("test") +public class RateLimiterServiceTest { @MockBean private FindMovieService findMovieService; diff --git a/module-api/src/test/java/org/example/service/reservation/ReservationServiceTest.java b/module-api/src/test/java/org/example/service/reservation/ReservationServiceTest.java new file mode 100644 index 000000000..1dc7b9d6b --- /dev/null +++ b/module-api/src/test/java/org/example/service/reservation/ReservationServiceTest.java @@ -0,0 +1,157 @@ +package org.example.service.reservation; + +import org.example.domain.reservation.Reservation; +import org.example.domain.reservationseat.ReservationSeat; +import org.example.domain.seat.Col; +import org.example.domain.seat.Row; +import org.example.domain.seat.Seat; +import org.example.dto.SeatsDto; +import org.example.exception.SeatException; +import org.example.repository.ReservationJpaRepository; +import org.example.repository.ReservationSeatRepository; +import org.example.repository.SeatJpaRepository; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.context.ActiveProfiles; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.example.baseresponse.BaseResponseStatus.CONCURRENT_RESERVATION_ERROR; +import static org.example.baseresponse.BaseResponseStatus.UNAVAILABLE_SEAT_ERROR; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@ActiveProfiles("test") +class ReservationServiceTest { + @InjectMocks + private SaveReservationService saveReservationService; + + @InjectMocks + private ReservationService reservationService; + + @Mock + private ReservationJpaRepository reservationJpaRepository; + + @Mock + private ReservationSeatRepository reservationSeatRepository; + + @Mock + private SeatJpaRepository seatJpaRepository; + + + @Test + @DisplayName("예약된 좌석이 없는 경우 예약 성공") + void reserve_Success() { + // Given + Long userId = 1L; + Long screenScheduleId = 1L; + List seats = new ArrayList<>(); + seats.add(Seat.of(1L, Row.ROW_A, Col.COL_1, 1L)); + seats.add(Seat.of(2L, Row.ROW_A, Col.COL_2, 1L)); + + // Mock: 좌석이 예약되지 않았음을 가정 + when(reservationSeatRepository.findReservedSeatBySeatId(anyLong(), anyLong())) + .thenReturn(Optional.empty()); // 예약된 좌석 없음 + + Reservation reservation = new Reservation(userId, screenScheduleId); + when(reservationJpaRepository.save(ArgumentMatchers.any())) + .thenReturn(reservation); + + // When + saveReservationService.saveReservationWithTransaction(userId, screenScheduleId, seats); + + // Then + verify(reservationJpaRepository, times(1)).save(ArgumentMatchers.any()); + verify(reservationSeatRepository, times(2)).save(ArgumentMatchers.any()); + } + + @Test + @DisplayName("예약된 좌석이 없는 경우 예외 발생") + void reserve_Fail() { + // Given + Long userId = 1L; + Long screenScheduleId = 1L; + List seats = new ArrayList<>(); + seats.add(Seat.of(1L, Row.ROW_A, Col.COL_1, 1L)); + seats.add(Seat.of(2L, Row.ROW_A, Col.COL_2, 1L)); + + // Mock: 첫 번째 좌석이 이미 예약됨 + when(reservationSeatRepository.findReservedSeatBySeatId(screenScheduleId, 1L)) + .thenReturn(Optional.of(new ReservationSeat(1L, 1L))); + + // When & Then + SeatException thrown = assertThrows(SeatException.class, () -> + saveReservationService.saveReservationWithTransaction(userId, screenScheduleId, seats) + ); + + Assertions.assertEquals(CONCURRENT_RESERVATION_ERROR, thrown.getExceptionStatus()); + verify(reservationJpaRepository, never()).save(ArgumentMatchers.any()); // 예약이 저장되지 않아야 함 + verify(reservationSeatRepository, never()).save(ArgumentMatchers.any()); // 좌석도 저장되지 않아야 함 + } + + @Test + @DisplayName("Seat 테이블에 좌석이 존재하면 조회에 성공한다.") + void getSeat_Success() { + // Given + Long screenRoomId = 1L; + List reservationSeats = List.of( + new SeatsDto(Row.ROW_A, Col.COL_1), + new SeatsDto(Row.ROW_A, Col.COL_2) + ); + + // Mock: 존재하는 좌석 설정 + given(seatJpaRepository.findSeats(screenRoomId, Row.ROW_A, Col.COL_1)) + .willReturn(Optional.of(new Seat(1L,Row.ROW_A, Col.COL_1, screenRoomId))); + + given(seatJpaRepository.findSeats(screenRoomId, Row.ROW_A, Col.COL_2)) + .willReturn(Optional.of(new Seat(2L, Row.ROW_A, Col.COL_2, screenRoomId))); + + // When + List seats = reservationService.validateReservedSeats(screenRoomId, reservationSeats); + + // Then + Assertions.assertEquals(2, seats.size()); + Assertions.assertEquals("A", seats.get(0).getRow().getRow()); + Assertions.assertEquals(1, seats.get(0).getCol().getColumn()); + Assertions.assertEquals("A", seats.get(1).getRow().getRow()); + Assertions.assertEquals(2, seats.get(1).getCol().getColumn()); + } + + @Test + @DisplayName("Seat 테이블에 좌석이 존재하지 않으면 예외가 발생한다.") + void getSeat_Fail() { + // Given + Long screenRoomId = 1L; + List reservationSeats = List.of( + new SeatsDto(Row.ROW_A, Col.COL_1), + new SeatsDto(Row.ROW_A, Col.COL_2) + ); + + // Mock: 존재하는 좌석 설정 + given(seatJpaRepository.findSeats(screenRoomId, Row.ROW_A, Col.COL_1)) + .willReturn(Optional.of(new Seat(1L,Row.ROW_A, Col.COL_1, screenRoomId))); + + given(seatJpaRepository.findSeats(screenRoomId, Row.ROW_A, Col.COL_2)) + .willReturn(Optional.empty()); + + // When & Then + SeatException thrown = assertThrows(SeatException.class, () -> + reservationService.validateReservedSeats(screenRoomId, reservationSeats) + ); + + Assertions.assertEquals(UNAVAILABLE_SEAT_ERROR, thrown.getExceptionStatus()); + } + + +} \ No newline at end of file diff --git a/module-api/src/test/resources/application.yml b/module-api/src/test/resources/application.yml index ddc6b12fa..5636a4738 100644 --- a/module-api/src/test/resources/application.yml +++ b/module-api/src/test/resources/application.yml @@ -1,9 +1,14 @@ spring: + config: + activate: + on-profile: test + + datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: ${DATASOURCE_URL} - username: ${DATASOURCE_USERNAME} - password: ${DATASOURCE_PASSWORD} + url: jdbc:mysql://localhost:3305/movie?allowPublicKeyRetrieval=true&useSSL=false + username: root + password: wjdthals1104 jpa: show-sql: true @@ -20,8 +25,8 @@ spring: data: redis: - host: ${DATA_REDIS_HOST} - port: ${DATA_REDIS_PORT} + host: localhost + port: 6378 sql: init: diff --git a/module-common/src/main/java/org/example/baseresponse/BaseResponse.java b/module-common/src/main/java/org/example/baseresponse/BaseResponse.java index 768e0c8dc..c7fb51e63 100644 --- a/module-common/src/main/java/org/example/baseresponse/BaseResponse.java +++ b/module-common/src/main/java/org/example/baseresponse/BaseResponse.java @@ -1,8 +1,11 @@ package org.example.baseresponse; +import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; import static org.example.baseresponse.BaseResponseStatus.SUCCESS; @@ -16,6 +19,7 @@ public class BaseResponse implements ResponseStatus { @JsonInclude(JsonInclude.Include.NON_NULL) private final T result; + @JsonCreator public BaseResponse(T result){ this.code = SUCCESS.getCode(); this.status = SUCCESS.getStatus(); diff --git a/module-common/src/main/java/org/example/baseresponse/BaseResponseStatus.java b/module-common/src/main/java/org/example/baseresponse/BaseResponseStatus.java index 91b2694e6..ceb1e9e1a 100644 --- a/module-common/src/main/java/org/example/baseresponse/BaseResponseStatus.java +++ b/module-common/src/main/java/org/example/baseresponse/BaseResponseStatus.java @@ -19,7 +19,8 @@ public enum BaseResponseStatus implements ResponseStatus { SEAT_ROW_DISCONTINUITY_ERROR(5004, HttpStatus.BAD_REQUEST.value(), "연속된 좌석만 예약할 수 있습니다. 행이 다릅니다."), SEAT_COLUMN_DISCONTINUITY_ERROR(5005, HttpStatus.BAD_REQUEST.value(), "연속된 좌석만 예약할 수 있습니다. 열이 연속되지 않았습니다."), ALREADY_RESERVED_SEAT_ERROR(5006, HttpStatus.BAD_REQUEST.value(), "이미 예약된 좌석입니다."), - TOO_MANY_REQUEST_ERROR(5007, HttpStatus.TOO_MANY_REQUESTS.value(), "너무 많은 요청이 들어왔습니다. 나중에 다시 시도해주세요."); + TOO_MANY_REQUEST_ERROR(5007, HttpStatus.TOO_MANY_REQUESTS.value(), "너무 많은 요청이 들어왔습니다. 나중에 다시 시도해주세요."), + UNAVAILABLE_SEAT_COUNT_ERROR(5008, HttpStatus.BAD_REQUEST.value(), "예약 가능한 좌석 개수가 아닙니다."); private final int code; private final int status; diff --git a/module-common/src/main/java/org/example/baseresponse/error/BaseErrorResponse.java b/module-common/src/main/java/org/example/baseresponse/error/BaseErrorResponse.java index 5a8d998b0..5230b317a 100644 --- a/module-common/src/main/java/org/example/baseresponse/error/BaseErrorResponse.java +++ b/module-common/src/main/java/org/example/baseresponse/error/BaseErrorResponse.java @@ -2,16 +2,19 @@ import com.fasterxml.jackson.annotation.JsonPropertyOrder; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.RequiredArgsConstructor; +import lombok.Setter; import org.example.baseresponse.ResponseStatus; @Getter -@RequiredArgsConstructor +@Setter +@NoArgsConstructor @JsonPropertyOrder({"code", "status", "message", "timestamp"}) public class BaseErrorResponse implements ResponseStatus { - private final int code; - private final int status; - private final String message; + private int code; + private int status; + private String message; public BaseErrorResponse(ResponseStatus status){ this.code = status.getCode(); diff --git a/module-common/src/main/java/org/example/exception/handler/SeatExceptionControllerAdvice.java b/module-common/src/main/java/org/example/exception/handler/SeatExceptionControllerAdvice.java index 4b0f59dab..34168c89c 100644 --- a/module-common/src/main/java/org/example/exception/handler/SeatExceptionControllerAdvice.java +++ b/module-common/src/main/java/org/example/exception/handler/SeatExceptionControllerAdvice.java @@ -11,7 +11,7 @@ @Slf4j @RestControllerAdvice public class SeatExceptionControllerAdvice { - @ResponseStatus(HttpStatus.OK) + @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler({SeatException.class}) public BaseErrorResponse handle_BaseException(SeatException e) { log.error("[handle_BadRequest]", e); diff --git a/module-domain/build.gradle b/module-domain/build.gradle index 849a80476..d0d91e98b 100644 --- a/module-domain/build.gradle +++ b/module-domain/build.gradle @@ -1,5 +1,6 @@ plugins { id 'java' + id 'jacoco' } jar.enabled = true @@ -9,4 +10,23 @@ dependencies { testImplementation platform('org.junit:junit-bom:5.9.1') testImplementation 'org.junit.jupiter:junit-jupiter' +} + +jacoco { + toolVersion = "0.8.10" +} + +jacocoTestReport { + dependsOn test + + reports { + xml.required = false + csv.required = false + html.required = true + } +} + +test { + useJUnitPlatform() + finalizedBy jacocoTestReport } \ No newline at end of file diff --git a/module-domain/src/main/java/org/example/domain/reservationseat/ReservationSeat.java b/module-domain/src/main/java/org/example/domain/reservationseat/ReservationSeat.java index ea2e86ebf..40a943c03 100644 --- a/module-domain/src/main/java/org/example/domain/reservationseat/ReservationSeat.java +++ b/module-domain/src/main/java/org/example/domain/reservationseat/ReservationSeat.java @@ -6,12 +6,23 @@ import lombok.Getter; import lombok.NoArgsConstructor; import org.example.domain.reservation.Reservation; +import org.example.domain.seat.Col; +import org.example.domain.seat.Row; +import org.example.dto.SeatsDto; import org.example.entity.BaseEntity; +import org.example.exception.SeatException; + +import java.util.List; + +import static org.example.baseresponse.BaseResponseStatus.*; +import static org.example.baseresponse.BaseResponseStatus.SEAT_COLUMN_DISCONTINUITY_ERROR; @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ReservationSeat extends BaseEntity { + private static final int MAX_SEAT_COUNT = 5; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "reservation_seat_id", nullable = false) @@ -35,4 +46,38 @@ public static ReservationSeat of(Long reservationId, Long seatId) { .seatId(seatId) .build(); } + + public static void validateCountExceeded(List reservationSeats, List seatsDtoByUserId) { + if (reservationSeats.size()+seatsDtoByUserId.size() > MAX_SEAT_COUNT) { + throw new SeatException(MAX_SEATS_EXCEEDED_ERROR); + } + } + + public static void containsReservedSeat(List reservationSeats, List seatsDtoByUserId) { + for (SeatsDto seatsDto : seatsDtoByUserId) { + for (SeatsDto reservationSeat : reservationSeats) { + if (seatsDto.getRow().equals(reservationSeat.getRow()) + && seatsDto.getCol().equals(reservationSeat.getCol())) { + throw new SeatException(ALREADY_RESERVED_SEAT_ERROR); + } + } + } + } + + public static void isSameRow(List reservationSeats, List seatsDtoByUserId) { + Row row = seatsDtoByUserId.get(0).getRow(); + for (SeatsDto reservationSeat : reservationSeats) { + if (!row.equals(reservationSeat.getRow())) { + throw new SeatException(SEAT_ROW_DISCONTINUITY_ERROR); + } + } + } + + public static void isContinuousCol(List reservationSeats, List seatsDtoByUserId) { + Col reservedCol = seatsDtoByUserId.get(seatsDtoByUserId.size() - 1).getCol(); + Col reservationCol = reservationSeats.get(0).getCol(); + if (reservationCol.getColumn() != reservedCol.getColumn()+1) { + throw new SeatException(SEAT_COLUMN_DISCONTINUITY_ERROR); + } + } } diff --git a/module-domain/src/main/java/org/example/domain/seat/Seat.java b/module-domain/src/main/java/org/example/domain/seat/Seat.java index eb8f698cb..6984c535d 100644 --- a/module-domain/src/main/java/org/example/domain/seat/Seat.java +++ b/module-domain/src/main/java/org/example/domain/seat/Seat.java @@ -2,10 +2,10 @@ import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.example.domain.reservationseat.ReservationSeat; import org.example.dto.SeatsDto; import org.example.entity.BaseEntity; import org.example.exception.SeatException; @@ -18,8 +18,8 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Slf4j public class Seat extends BaseEntity { + private static final int MIN_SEAT_COUNT = 1; private static final int MAX_SEAT_COUNT = 5; @Id @@ -38,9 +38,26 @@ public class Seat extends BaseEntity { @Column(nullable = false) private Long screenRoomId; - public static void validateCountExceeded(int seatSize) { - if (seatSize > MAX_SEAT_COUNT) { - throw new SeatException(MAX_SEATS_EXCEEDED_ERROR); + @Builder + public Seat(Long id, Row row, Col col, Long screenRoomId) { + this.id = id; + this.row = row; + this.col = col; + this.screenRoomId = screenRoomId; + } + + public static Seat of(Long id, Row row, Col col, Long screenRoomId) { + return Seat.builder() + .id(id) + .row(row) + .col(col) + .screenRoomId(screenRoomId) + .build(); + } + + public static void validateSeatCount(int seatSize) { + if (seatSize > MAX_SEAT_COUNT || seatSize < MIN_SEAT_COUNT) { + throw new SeatException(UNAVAILABLE_SEAT_COUNT_ERROR); } } @@ -62,32 +79,4 @@ public static void validateContinuousSeats(List seats) { } } } - - public static void containsReservedSeat(List reservationSeats, List seatsDtoByUserId) { - for (SeatsDto seatsDto : seatsDtoByUserId) { - for (SeatsDto reservationSeat : reservationSeats) { - if (seatsDto.getRow().equals(reservationSeat.getRow()) - && seatsDto.getCol().equals(reservationSeat.getCol())) { - throw new SeatException(ALREADY_RESERVED_SEAT_ERROR); - } - } - } - } - - public static void isSameRow(List reservationSeats, List seatsDtoByUserId) { - Row row = seatsDtoByUserId.get(0).getRow(); - for (SeatsDto reservationSeat : reservationSeats) { - if (!row.equals(reservationSeat.getRow())) { - throw new SeatException(SEAT_ROW_DISCONTINUITY_ERROR); - } - } - } - - public static void isContinuousCol(List reservationSeats, List seatsDtoByUserId) { - Col reservedCol = seatsDtoByUserId.get(seatsDtoByUserId.size() - 1).getCol(); - Col reservationCol = reservationSeats.get(0).getCol(); - if (reservationCol.getColumn() != reservedCol.getColumn()+1) { - throw new SeatException(SEAT_COLUMN_DISCONTINUITY_ERROR); - } - } } diff --git a/module-domain/src/main/java/org/example/repository/MovieJpaRepository.java b/module-domain/src/main/java/org/example/repository/MovieJpaRepository.java index 860192de9..c3eeab800 100644 --- a/module-domain/src/main/java/org/example/repository/MovieJpaRepository.java +++ b/module-domain/src/main/java/org/example/repository/MovieJpaRepository.java @@ -17,6 +17,6 @@ public interface MovieJpaRepository extends JpaRepository { "join ScreenRoom sr on ss.screenRoomId = sr.id " + "where (:title IS NULL OR m.title LIKE %:title%) " + "AND (:genre IS NULL OR m.genre = :genre) " + - "And (m.isPlaying = :isPlaying)") - List findScreeningInfos(@Param("title") String title, @Param("genre") Genre genre, @Param("isPlaying") boolean isPlaying); + "And (m.isPlaying = true)") + List findScreeningInfos(@Param("title") String title, @Param("genre") Genre genre); } \ No newline at end of file diff --git a/module-domain/src/test/java/org/example/domain/reservationseat/ReservationSeatTest.java b/module-domain/src/test/java/org/example/domain/reservationseat/ReservationSeatTest.java new file mode 100644 index 000000000..a35786aca --- /dev/null +++ b/module-domain/src/test/java/org/example/domain/reservationseat/ReservationSeatTest.java @@ -0,0 +1,112 @@ +package org.example.domain.reservationseat; + +import org.assertj.core.api.Assertions; +import org.example.domain.seat.Col; +import org.example.domain.seat.Row; +import org.example.dto.SeatsDto; +import org.example.exception.SeatException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ReservationSeatTest { + + @Test + @DisplayName("이미 예매한 좌석과 예매하려는 좌석의 합이 5개가 넘으면 예외가 발생한다.") + void exceedTotalReservationSeat_ThrowException() { + // given + List reservationSeats = new ArrayList<>(); + reservationSeats.add(new SeatsDto(Row.ROW_A, Col.COL_1)); + reservationSeats.add(new SeatsDto(Row.ROW_A, Col.COL_2)); + reservationSeats.add(new SeatsDto(Row.ROW_A, Col.COL_3)); + + List reservedSeats = new ArrayList<>(); + reservedSeats.add(new SeatsDto(Row.ROW_A, Col.COL_4)); + reservedSeats.add(new SeatsDto(Row.ROW_A, Col.COL_5)); + reservedSeats.add(new SeatsDto(Row.ROW_B, Col.COL_1)); + + // when + Throwable thrown = assertThrows(SeatException.class, () -> + ReservationSeat.validateCountExceeded(reservationSeats, reservedSeats) + ); + + // then + Assertions.assertThat(thrown) + .isInstanceOf(SeatException.class) + .hasMessage("최대 예약 가능한 좌석을 초과했습니다."); + } + + @Test + @DisplayName("이미 예매한 좌석을 같은 사용자가 또 예매하려고 하면 예외가 발생한다.") + void containsReservedSeat_ThrowException() { + // given + List reservationSeats = new ArrayList<>(); + reservationSeats.add(new SeatsDto(Row.ROW_A, Col.COL_1)); + reservationSeats.add(new SeatsDto(Row.ROW_A, Col.COL_2)); + + List reservedSeats = new ArrayList<>(); + reservedSeats.add(new SeatsDto(Row.ROW_A, Col.COL_2)); + reservedSeats.add(new SeatsDto(Row.ROW_A, Col.COL_3)); + + // when + Throwable thrown = assertThrows(SeatException.class, () -> + ReservationSeat.containsReservedSeat(reservationSeats, reservedSeats) + ); + + // then + Assertions.assertThat(thrown) + .isInstanceOf(SeatException.class) + .hasMessage("이미 예약된 좌석입니다."); + } + + @Test + @DisplayName("같은 사용자가 이미 예매한 좌석들과 다른 행을 예매하려고 하면 예외가 발생한다.") + void notSameRow_ThrowException() { + // given + List reservationSeats = new ArrayList<>(); + reservationSeats.add(new SeatsDto(Row.ROW_A, Col.COL_1)); + reservationSeats.add(new SeatsDto(Row.ROW_A, Col.COL_2)); + + List reservedSeats = new ArrayList<>(); + reservedSeats.add(new SeatsDto(Row.ROW_B, Col.COL_3)); + reservedSeats.add(new SeatsDto(Row.ROW_B, Col.COL_4)); + + // when + Throwable thrown = assertThrows(SeatException.class, () -> + ReservationSeat.isSameRow(reservationSeats, reservedSeats) + ); + + // then + Assertions.assertThat(thrown) + .isInstanceOf(SeatException.class) + .hasMessage("연속된 좌석만 예약할 수 있습니다. 행이 다릅니다."); + } + + @Test + @DisplayName("같은 사용자가 이미 예매한 좌석들과 연속적이지 않은 열을 예매하려고 하면 예외가 발생한다.") + void notContinuousCol_ThrowException() { + // given + List reservationSeats = new ArrayList<>(); + reservationSeats.add(new SeatsDto(Row.ROW_A, Col.COL_1)); + reservationSeats.add(new SeatsDto(Row.ROW_A, Col.COL_2)); + + List reservedSeats = new ArrayList<>(); + reservedSeats.add(new SeatsDto(Row.ROW_A, Col.COL_4)); + reservedSeats.add(new SeatsDto(Row.ROW_A, Col.COL_5)); + + // when + Throwable thrown = assertThrows(SeatException.class, () -> + ReservationSeat.isContinuousCol(reservationSeats, reservedSeats) + ); + + // then + Assertions.assertThat(thrown) + .isInstanceOf(SeatException.class) + .hasMessage("연속된 좌석만 예약할 수 있습니다. 열이 연속되지 않았습니다."); + } + +} \ No newline at end of file diff --git a/module-domain/src/test/java/org/example/domain/seat/SeatTest.java b/module-domain/src/test/java/org/example/domain/seat/SeatTest.java new file mode 100644 index 000000000..e2ce1d8bd --- /dev/null +++ b/module-domain/src/test/java/org/example/domain/seat/SeatTest.java @@ -0,0 +1,86 @@ +package org.example.domain.seat; + +import org.assertj.core.api.Assertions; +import org.example.dto.SeatsDto; +import org.example.exception.SeatException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class SeatTest { + @Test + @DisplayName("예매하려는 좌석이 5개가 넘을 때 예외가 발생한다.") + void exceedCount_6Seat_ThrowException() { + // given + int seatSize = 6; + + // when + Throwable thrown = assertThrows(SeatException.class, () -> + Seat.validateSeatCount(seatSize) + ); + + // then + Assertions.assertThat(thrown) + .isInstanceOf(SeatException.class) + .hasMessage("예약 가능한 좌석 개수가 아닙니다."); + } + + @Test + @DisplayName("예매하려는 좌석이 0개일 때 예외가 발생한다.") + void exceedCount_0Seat_ThrowException() { + // given + int seatSize = 0; + + // when + Throwable thrown = assertThrows(SeatException.class, () -> + Seat.validateSeatCount(seatSize) + ); + + // then + Assertions.assertThat(thrown) + .isInstanceOf(SeatException.class) + .hasMessage("예약 가능한 좌석 개수가 아닙니다."); + } + + @Test + @DisplayName("예매하려는 좌석들의 행이 다르면 예외가 발생한다.") + void continuousSeats_NotSameRow_ThrowException() { + // given + List seats = new ArrayList<>(); + seats.add(new SeatsDto(Row.ROW_A, Col.COL_1)); + seats.add(new SeatsDto(Row.ROW_B, Col.COL_1)); + + // when + Throwable thrown = assertThrows(SeatException.class, () -> + Seat.validateContinuousSeats(seats) + ); + + // then + Assertions.assertThat(thrown) + .isInstanceOf(SeatException.class) + .hasMessage("연속된 좌석만 예약할 수 있습니다. 행이 다릅니다."); + } + + @Test + @DisplayName("예매하려는 좌석들이 연속적이지 않으면 다르면 예외가 발생한다.") + void continuousSeats_NotContinuousCol_ThrowException() { + // given + List seats = new ArrayList<>(); + seats.add(new SeatsDto(Row.ROW_A, Col.COL_1)); + seats.add(new SeatsDto(Row.ROW_A, Col.COL_3)); + + // when + Throwable thrown = assertThrows(SeatException.class, () -> + Seat.validateContinuousSeats(seats) + ); + + // then + Assertions.assertThat(thrown) + .isInstanceOf(SeatException.class) + .hasMessage("연속된 좌석만 예약할 수 있습니다. 열이 연속되지 않았습니다."); + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 3ec2d347e..d1ee2174b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,6 +1,5 @@ rootProject.name = 'redis_1st' include 'module-api' include 'module-domain' -include 'module-core' include 'module-common'