diff --git a/build.gradle b/build.gradle index 809fecbd6..b94bbf574 100644 --- a/build.gradle +++ b/build.gradle @@ -67,6 +67,9 @@ subprojects { // 모든 하위 모듈들에 이 설정을 적용합니다. // validation implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Guava Rate Limiter + implementation("com.google.guava:guava:31.1-jre") } test { diff --git a/module-api/src/main/java/org/example/controller/MovieController.java b/module-api/src/main/java/org/example/controller/MovieController.java index ec8f612bc..775b851c8 100644 --- a/module-api/src/main/java/org/example/controller/MovieController.java +++ b/module-api/src/main/java/org/example/controller/MovieController.java @@ -1,11 +1,14 @@ package org.example.controller; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.example.baseresponse.BaseResponse; import org.example.dto.request.MoviesFilterRequestDto; import org.example.dto.response.PlayingMoviesResponseDto; +import org.example.exception.RateLimitExceededException; +import org.example.service.RedisRateLimiterService; import org.example.service.movie.MovieService; -import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; @@ -13,15 +16,40 @@ import java.util.List; +import static org.example.baseresponse.BaseResponseStatus.TOO_MANY_REQUEST_ERROR; + @Slf4j @RestController @RequiredArgsConstructor public class MovieController { private final MovieService movieService; + private final RedisRateLimiterService redisRateLimiterService; @GetMapping("/movies/playing") - public ResponseEntity> getPlayingMovies(@ModelAttribute @Validated MoviesFilterRequestDto moviesFilterRequestDto) { + public BaseResponse> getPlayingMovies(@ModelAttribute @Validated MoviesFilterRequestDto moviesFilterRequestDto, HttpServletRequest request) { + String clientIp = getClientIP(request); + + // IP 차단 여부 확인 + Long allowed = redisRateLimiterService.isAllowed(clientIp); + if (allowed == -1) { + throw new RateLimitExceededException(TOO_MANY_REQUEST_ERROR); + } + List playingMovies = movieService.getPlayingMovies(moviesFilterRequestDto); - return ResponseEntity.ok(playingMovies); + return new BaseResponse<>(playingMovies); + } + + private String getClientIP(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("Proxy-Client-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("WL-Proxy-Client-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + return ip; } } 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 2fe82a875..39fa6bfda 100644 --- a/module-api/src/main/java/org/example/controller/ReservationController.java +++ b/module-api/src/main/java/org/example/controller/ReservationController.java @@ -1,6 +1,8 @@ package org.example.controller; import lombok.RequiredArgsConstructor; +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; @@ -10,15 +12,16 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import static org.example.baseresponse.BaseResponseStatus.SUCCESS; + @RestController @RequiredArgsConstructor public class ReservationController { private final ReservationService reservationService; @PostMapping("/reservation") - public ResponseEntity getPlayingMovies(@RequestBody @Validated ReservationRequestDto reservationRequestDto) { + public BaseResponse getPlayingMovies(@RequestBody @Validated ReservationRequestDto reservationRequestDto) { reservationService.reserveMovie(reservationRequestDto); - return ResponseEntity.ok("예약이 완료되었습니다."); + return new BaseResponse<>(SUCCESS); } - } diff --git a/module-api/src/main/java/org/example/dto/request/ReservationRequestDto.java b/module-api/src/main/java/org/example/dto/request/ReservationRequestDto.java index cbabbe0f6..a6ffd2c08 100644 --- a/module-api/src/main/java/org/example/dto/request/ReservationRequestDto.java +++ b/module-api/src/main/java/org/example/dto/request/ReservationRequestDto.java @@ -8,5 +8,5 @@ public record ReservationRequestDto( @NotNull Long usersId, @NotNull Long screenScheduleId, - @NotNull @Valid List reservationSeats + @NotNull @Valid List reservationSeats ) {} diff --git a/module-api/src/main/java/org/example/dto/request/ReservationSeat.java b/module-api/src/main/java/org/example/dto/request/ReservationSeatDto.java similarity index 91% rename from module-api/src/main/java/org/example/dto/request/ReservationSeat.java rename to module-api/src/main/java/org/example/dto/request/ReservationSeatDto.java index 8c0805338..f81f24cc3 100644 --- a/module-api/src/main/java/org/example/dto/request/ReservationSeat.java +++ b/module-api/src/main/java/org/example/dto/request/ReservationSeatDto.java @@ -4,7 +4,7 @@ import org.example.domain.seat.Col; import org.example.domain.seat.Row; -public record ReservationSeat( +public record ReservationSeatDto( @ValidEnum(enumClass = Row.class, message = "올바른 행 이름을 입력해주세요.") String row, @ValidEnum(enumClass = Col.class, message = "올바른 열 이름을 입력해주세요.") diff --git a/module-api/src/main/java/org/example/service/RedisRateLimiterService.java b/module-api/src/main/java/org/example/service/RedisRateLimiterService.java new file mode 100644 index 000000000..7021cedbf --- /dev/null +++ b/module-api/src/main/java/org/example/service/RedisRateLimiterService.java @@ -0,0 +1,43 @@ +package org.example.service; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.stereotype.Service; + +import java.util.Collections; + +@Slf4j +@Service +@AllArgsConstructor +public class RedisRateLimiterService { + private final StringRedisTemplate redisTemplate; + + private static final String LUA_SCRIPT = + "local ip = KEYS[1]\n" + + "local blockKey = 'block:' .. ip\n" + + "local requestKey = 'request:' .. ip\n" + + + "if redis.call('EXISTS', blockKey) == 1 then\n" + + " return -1\n" + + "end\n" + + + "local currentCount = tonumber(redis.call('GET', requestKey) or '0')\n" + + "redis.call('SET', requestKey, currentCount) \n" + + + "if currentCount >= tonumber('50') then\n" + + " redis.call('SETEX', blockKey, 3600, 'BLOCKED')\n" + + " redis.call('DEL', requestKey)\n" + + " return -1\n" + + "end\n" + + + "currentCount = redis.call('INCR', requestKey)\n" + + + "return currentCount"; + + public Long isAllowed(String ip) { + Long result = redisTemplate.execute(RedisScript.of(LUA_SCRIPT, Long.class), Collections.singletonList(ip)); + return result; + } +} diff --git a/module-api/src/main/java/org/example/service/ReservationService.java b/module-api/src/main/java/org/example/service/ReservationService.java index 103c38110..e372f981e 100644 --- a/module-api/src/main/java/org/example/service/ReservationService.java +++ b/module-api/src/main/java/org/example/service/ReservationService.java @@ -1,147 +1,121 @@ package org.example.service; -import jakarta.persistence.OptimisticLockException; +import jakarta.transaction.Transactional; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.example.common.exception.SeatException; +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; import org.example.domain.seat.Seat; -import org.example.dto.ReservedSeats; +import org.example.dto.SeatsDto; import org.example.dto.request.ReservationRequestDto; -import org.example.dto.request.ReservationSeat; +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; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; -import java.util.Comparator; import java.util.List; -import static org.example.common.response.BaseResponseStatus.*; +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 static final int MAX_SEAT_COUNT = 5; - private final ReservationJpaRepository reservationJpaRepository; private final SeatJpaRepository seatJpaRepository; private final ScreenScheduleJpaRepository screenScheduleJpaRepository; + private final ReservationSeatRepository reservationSeatRepository; + private final RedissonLockUtil redissonLockUtil; - @Transactional public void reserveMovie(ReservationRequestDto reservationRequestDto) { - validateSeats(reservationRequestDto.reservationSeats()); + List reservationSeats = new ArrayList<>( + reservationRequestDto.reservationSeats().stream() + .map(seat -> new SeatsDto(Row.valueOf(seat.row()), Col.valueOf(seat.col()))) + .toList() + ); Long screenRoomId = screenScheduleJpaRepository.findScreenRoomIdById(reservationRequestDto.screenScheduleId()); - validateUserReserveSeats(reservationRequestDto.reservationSeats(), reservationRequestDto.usersId(), reservationRequestDto.screenScheduleId()); - try { - List seats = new ArrayList<>(); - for (ReservationSeat reservationSeat : reservationRequestDto.reservationSeats()) { - Row row = Row.valueOf(reservationSeat.row()); - Col col = Col.valueOf(reservationSeat.col()); + // 예약하려는 좌석 검증 + validateSeats(reservationSeats); - Seat seat = seatJpaRepository.findSeats(screenRoomId, row, col) - .orElseThrow(() -> new SeatException(UNAVAILABLE_SEAT_ERROR)); - - seats.add(seat); - } - saveReservation(reservationRequestDto.usersId(), reservationRequestDto.screenScheduleId(), seats); - } catch (OptimisticLockException e) { - throw new SeatException(CONCURRENT_RESERVATION_ERROR); - } - } + // 사용자가 동일한 상영에 대해 예약한 좌석 검증 + validateUserReserveSeats(reservationSeats, reservationRequestDto.usersId(), reservationRequestDto.screenScheduleId()); - private void validateSeats(List seats) { - validateSeatCount(seats); - validateContinuousSeats(seats); - } + // 개별 좌석별 락 키 생성 + List lockKeys = reservationRequestDto.reservationSeats().stream() + .map(seat -> "lock:seat:" + reservationRequestDto.screenScheduleId() + ":" + seat.row() + ":" + seat.col()) + .toList(); - private void validateSeatCount(List seats) { - if (seats.size() > MAX_SEAT_COUNT) { - throw new SeatException(MAX_SEATS_EXCEEDED_ERROR); - } + // Redisson MultiLock 적용 (여러 개의 좌석을 동시에 보호) + redissonLockUtil.executeWithMultiLock(lockKeys, 5, 10, () -> { + saveReservationWithTransaction(reservationRequestDto, reservationSeats, screenRoomId); + return null; + }); } - private void validateContinuousSeats(List seats) { - seats.sort(Comparator.comparing(ReservationSeat::col)); - - for (int i = 1; i < seats.size(); i++) { - ReservationSeat prev = seats.get(i - 1); - ReservationSeat current = seats.get(i); - - if (!prev.row().equals(current.row())) { - throw new SeatException(SEAT_ROW_DISCONTINUITY_ERROR); - } + @Transactional + public void saveReservationWithTransaction(ReservationRequestDto reservationRequestDto, List reservationSeats, Long screenRoomId) { + // 좌석에 락을 걸고 저장할 좌석들 반환 + List seats = validateReservedSeats(screenRoomId, reservationSeats); - int prevCol = Col.valueOf(prev.col()).getColumn(); - int currentCol = Col.valueOf(current.col()).getColumn(); - if (currentCol != prevCol + 1) { - throw new SeatException(SEAT_COLUMN_DISCONTINUITY_ERROR); + // 예약된 좌석인지 검증 + for (Seat seat : seats) { + boolean isReserved = reservationSeatRepository.findReservedSeatBySeatId(reservationRequestDto.screenScheduleId(), seat.getId()).isPresent(); + if (isReserved) { + throw new SeatException(ALREADY_RESERVED_SEAT_ERROR); } } - } - - private void validateUserReserveSeats(List reservationSeats, Long userId, Long screenScheduleId) { - List reservedSeatsByUserId = reservationJpaRepository.findReservedSeatByUserIdAndScreenScheduleId(userId, screenScheduleId); - if (reservedSeatsByUserId.isEmpty()) { - return; - } - isInMaxCount(reservationSeats, reservedSeatsByUserId); // 예약하려는 좌석이 5개 이상인지 - containsReservedSeat(reservationSeats, reservedSeatsByUserId); // 이미 예약된 좌석과 겹치는지 - isSameRow(reservationSeats, reservedSeatsByUserId); // 좌석이 같은 행에 있는지 - isContinuousCol(reservationSeats, reservedSeatsByUserId); // 좌석이 연속된 열인지 + Long reservationId = saveReservation(reservationRequestDto.usersId(), reservationRequestDto.screenScheduleId()); + saveReservationSeats(seats, reservationId); } - private void isInMaxCount(List reservationSeats, List reservedSeatsByUserId) { - if (reservationSeats.size() + reservedSeatsByUserId.size() > MAX_SEAT_COUNT) { - throw new SeatException(MAX_SEATS_EXCEEDED_ERROR); - } + private void validateSeats(List seats) { + Seat.validateCountExceeded(seats.size()); + Seat.validateContinuousSeats(seats); } - private void containsReservedSeat(List reservationSeats, List reservedSeatsByUserId) { - for (ReservedSeats reservedSeats : reservedSeatsByUserId) { - for (ReservationSeat reservationSeat : reservationSeats) { - if (reservedSeats.getRow().equals(Row.valueOf(reservationSeat.row())) - && reservedSeats.getCol().equals(Col.valueOf(reservationSeat.col()))) { - throw new SeatException(ALREADY_RESERVED_SEAT_ERROR); - } - } + private void validateUserReserveSeats(List reservationSeats, Long userId, Long screenScheduleId) { + List reservedSeats = reservationSeatRepository.findReservedSeatByUserIdAndScreenScheduleId(userId, screenScheduleId); + if (reservedSeats.isEmpty()) { + return; } + + Seat.validateCountExceeded(reservationSeats.size() + reservedSeats.size()); // 예약하려는 좌석이 5개 이상인지 + Seat.containsReservedSeat(reservationSeats, reservedSeats); // 이미 예약된 좌석과 겹치는지 + Seat.isSameRow(reservationSeats, reservedSeats); // 좌석이 같은 행에 있는지 + Seat.isContinuousCol(reservationSeats, reservedSeats); // 좌석이 연속된 열인지 } - private void isSameRow(List reservationSeats, List reservedSeatsByUserId) { - Row row = reservedSeatsByUserId.get(0).getRow(); - for (ReservationSeat reservationSeat : reservationSeats) { - if (!row.equals(Row.valueOf(reservationSeat.row()))) { - throw new SeatException(SEAT_ROW_DISCONTINUITY_ERROR); - } + 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 void isContinuousCol(List reservationSeats, List reservedSeatsByUserId) { - Col reservedCol = reservedSeatsByUserId.get(reservedSeatsByUserId.size() - 1).getCol(); - Col reservationCol = Col.valueOf(reservationSeats.get(0).col()); - if (reservationCol.getColumn() != reservedCol.getColumn()+1) { - throw new SeatException(SEAT_COLUMN_DISCONTINUITY_ERROR); - } + private Long saveReservation(Long userId, Long screenScheduleId) { + Reservation reservation = Reservation.of(userId, screenScheduleId); + Reservation savedReservation = reservationJpaRepository.save(reservation); + return savedReservation.getId(); } - private void saveReservation(Long userId, Long screenScheduleId, List seats) { + private void saveReservationSeats(List seats, Long reservationId) { for (Seat seat : seats) { - boolean isReserved = reservationJpaRepository.existsByUsersIdAndScreenScheduleIdAndSeatId(userId, screenScheduleId, seat.getId()); - if (isReserved) { - throw new SeatException(ALREADY_RESERVED_SEAT_ERROR); - } - - Reservation reservation = Reservation.create(userId, screenScheduleId, seat.getId()); - reservationJpaRepository.save(reservation); + ReservationSeat reservationSeat = ReservationSeat.of(reservationId, seat.getId()); + reservationSeatRepository.save(reservationSeat); } } -} +} \ No newline at end of file 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 4a5b7ca53..235266cb2 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,10 +19,16 @@ public class FindMovieService { private final MovieJpaRepository movieJpaRepository; @Cacheable(value = "playingMovies", - key = "#moviesFilterRequestDto.genre + #moviesFilterRequestDto.playing") + key = "(#moviesFilterRequestDto.genre != null ? #moviesFilterRequestDto.genre : 'ALL') " + + "+ (#moviesFilterRequestDto.playing != null ? #moviesFilterRequestDto.playing : false)") public FoundMovieScreeningInfoList getPlayingMovies(MoviesFilterRequestDto moviesFilterRequestDto) { + Genre genre = null; + if (moviesFilterRequestDto.getGenre() != null) { + genre = Genre.valueOf(moviesFilterRequestDto.getGenre()); + } + List movieScreeningInfos - = movieJpaRepository.findScreeningInfos(moviesFilterRequestDto.getMovieTitle(), Genre.valueOf(moviesFilterRequestDto.getGenre()), moviesFilterRequestDto.isPlaying()); + = movieJpaRepository.findScreeningInfos(moviesFilterRequestDto.getMovieTitle(), genre, moviesFilterRequestDto.isPlaying()); 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 d67c9d1ce..45b9c8dd2 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 @@ -1,5 +1,6 @@ package org.example.service.movie; +import com.google.common.util.concurrent.RateLimiter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.example.dto.MovieScreeningInfo; @@ -7,12 +8,12 @@ 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.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; + +import static org.example.baseresponse.BaseResponseStatus.TOO_MANY_REQUEST_ERROR; @Slf4j @Service @@ -20,12 +21,21 @@ public class MovieService { private final FindMovieService findMovieService; + // 초당 2개의 요청을 허용 + public final RateLimiter rateLimiter; + public List getPlayingMovies(MoviesFilterRequestDto moviesFilterRequestDto) { +// if (!rateLimiter.tryAcquire()) { +// throw new RateLimitExceededException(TOO_MANY_REQUEST_ERROR); +// } + List movieScreeningInfos = findMovieService.getPlayingMovies(moviesFilterRequestDto).getMovieScreeningInfos() - .stream() - .filter(movieScreeningInfo -> movieScreeningInfo.getTitle().contains(moviesFilterRequestDto.getMovieTitle())) - .toList(); + .stream() + .filter(movieScreeningInfo -> + Optional.ofNullable(movieScreeningInfo.getTitle()).orElse("") + .contains(Optional.ofNullable(moviesFilterRequestDto.getMovieTitle()).orElse(""))) + .toList(); Map movieInfoMap = new HashMap<>(); diff --git a/module-api/src/main/resources/application.yml b/module-api/src/main/resources/application.yml index ddc6b12fa..69ee4f8d5 100644 --- a/module-api/src/main/resources/application.yml +++ b/module-api/src/main/resources/application.yml @@ -6,10 +6,10 @@ spring: password: ${DATASOURCE_PASSWORD} jpa: - show-sql: true properties: hibernate: format_sql: true + show-sql: true dialect: org.hibernate.dialect.MySQLDialect hibernate: ddl-auto: none diff --git a/module-api/src/test/java/org/example/ApiApplicationTest.java b/module-api/src/test/java/org/example/ApiApplicationTest.java index 57e288cf7..d213b6416 100644 --- a/module-api/src/test/java/org/example/ApiApplicationTest.java +++ b/module-api/src/test/java/org/example/ApiApplicationTest.java @@ -1,12 +1,110 @@ package org.example; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +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.*; -@SpringBootTest +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc class ApiApplicationTest { + @LocalServerPort + private int port; + + @Autowired + private TestRestTemplate restTemplate; + + private static final String TEST_IP = "127.0.0.1"; + @Test - void contextLoads() { + 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()); } } \ 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/GuavaRateLimiterTest.java new file mode 100644 index 000000000..ee436883b --- /dev/null +++ b/module-api/src/test/java/org/example/GuavaRateLimiterTest.java @@ -0,0 +1,47 @@ +package org.example; + +import com.google.common.util.concurrent.RateLimiter; +import org.example.service.movie.FindMovieService; +import org.example.service.movie.MovieService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockitoAnnotations; +import org.springframework.boot.test.mock.mockito.MockBean; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class GuavaRateLimiterTest { + @MockBean + private FindMovieService findMovieService; + + private MovieService movieService; + + private RateLimiter rateLimiter; + + @BeforeEach + void setUp() throws InterruptedException { + MockitoAnnotations.openMocks(this); + rateLimiter = RateLimiter.create(2.0); // 초당 2개의 요청을 허용하는 RateLimiter + movieService = new MovieService(findMovieService, rateLimiter); + Thread.sleep(500); + } + + @Test + void testRateLimiter_AllowsOnlyTwoRequestsPerSecond() { + assertTrue(movieService.rateLimiter.tryAcquire()); // 첫 번째 요청 허용 + assertTrue(movieService.rateLimiter.tryAcquire()); // 두 번째 요청 허용 + assertFalse(movieService.rateLimiter.tryAcquire()); // 세 번째 요청 거부 + } + + @Test + void testRateLimiter_AllowsRequestAfterDelay() throws InterruptedException { + assertTrue(movieService.rateLimiter.tryAcquire()); // 첫 번째 요청 허용 + assertTrue(movieService.rateLimiter.tryAcquire()); // 두 번째 요청 허용 + assertFalse(movieService.rateLimiter.tryAcquire()); // 세 번째 요청 거부 + + Thread.sleep(1000); // 1초 후 토큰이 다시 충전됨 + + assertTrue(movieService.rateLimiter.tryAcquire()); // 다시 요청 가능 + } +} diff --git a/module-api/src/test/java/org/example/ReservationConcurrencyTest.java b/module-api/src/test/java/org/example/ReservationConcurrencyTest.java index d07f645e2..fe2aff7fc 100644 --- a/module-api/src/test/java/org/example/ReservationConcurrencyTest.java +++ b/module-api/src/test/java/org/example/ReservationConcurrencyTest.java @@ -1,9 +1,11 @@ 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.ReservationSeat; +import org.example.dto.request.ReservationSeatDto; import org.example.repository.ReservationJpaRepository; +import org.example.repository.ReservationSeatRepository; import org.example.service.ReservationService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -23,7 +25,7 @@ public class ReservationConcurrencyTest { private ReservationService reservationService; @Autowired - private ReservationJpaRepository reservationJpaRepository; + private ReservationSeatRepository reservationSeatRepository; @Test void testConcurrentReservation() throws InterruptedException { @@ -31,11 +33,11 @@ void testConcurrentReservation() throws InterruptedException { ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads); CountDownLatch latch = new CountDownLatch(numberOfThreads); - List reservationSeats = new ArrayList<>(); - reservationSeats.add(new ReservationSeat("ROW_A", "COL_1")); + List reservationSeatDtos = new ArrayList<>(); + reservationSeatDtos.add(new ReservationSeatDto("ROW_A", "COL_1")); for (long i = 0; i < numberOfThreads; i++) { - ReservationRequestDto reservationRequestDto = new ReservationRequestDto(i, 2L, reservationSeats); + ReservationRequestDto reservationRequestDto = new ReservationRequestDto(i, 2L, reservationSeatDtos); executorService.execute(() -> { try { reservationService.reserveMovie(reservationRequestDto); @@ -50,7 +52,7 @@ void testConcurrentReservation() throws InterruptedException { latch.await(); executorService.shutdown(); - List reservedSeatByUserId = reservationJpaRepository.findReservedSeatByScreenScheduleId(2L); - Assertions.assertThat(reservedSeatByUserId.size()).isEqualTo(1); + List reservedSeats = reservationSeatRepository.findReservedSeatByScreenScheduleId(2L); + Assertions.assertThat(reservedSeats.size()).isEqualTo(1); } } diff --git a/module-api/src/main/java/org/example/common/response/BaseResponse.java b/module-common/src/main/java/org/example/baseresponse/BaseResponse.java similarity index 88% rename from module-api/src/main/java/org/example/common/response/BaseResponse.java rename to module-common/src/main/java/org/example/baseresponse/BaseResponse.java index 5172a08cc..768e0c8dc 100644 --- a/module-api/src/main/java/org/example/common/response/BaseResponse.java +++ b/module-common/src/main/java/org/example/baseresponse/BaseResponse.java @@ -1,10 +1,10 @@ -package org.example.common.response; +package org.example.baseresponse; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import lombok.Getter; -import static org.example.common.response.BaseResponseStatus.SUCCESS; +import static org.example.baseresponse.BaseResponseStatus.SUCCESS; @Getter @JsonPropertyOrder({"code", "status", "message", "result"}) diff --git a/module-api/src/main/java/org/example/common/response/BaseResponseStatus.java b/module-common/src/main/java/org/example/baseresponse/BaseResponseStatus.java similarity index 85% rename from module-api/src/main/java/org/example/common/response/BaseResponseStatus.java rename to module-common/src/main/java/org/example/baseresponse/BaseResponseStatus.java index e0229d9c5..91b2694e6 100644 --- a/module-api/src/main/java/org/example/common/response/BaseResponseStatus.java +++ b/module-common/src/main/java/org/example/baseresponse/BaseResponseStatus.java @@ -1,4 +1,4 @@ -package org.example.common.response; +package org.example.baseresponse; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -18,8 +18,8 @@ public enum BaseResponseStatus implements ResponseStatus { MAX_SEATS_EXCEEDED_ERROR(5003, HttpStatus.BAD_REQUEST.value(), "최대 예약 가능한 좌석을 초과했습니다."), 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(), "이미 예약된 좌석입니다."); - + ALREADY_RESERVED_SEAT_ERROR(5006, HttpStatus.BAD_REQUEST.value(), "이미 예약된 좌석입니다."), + TOO_MANY_REQUEST_ERROR(5007, HttpStatus.TOO_MANY_REQUESTS.value(), "너무 많은 요청이 들어왔습니다. 나중에 다시 시도해주세요."); private final int code; private final int status; diff --git a/module-api/src/main/java/org/example/common/response/ResponseStatus.java b/module-common/src/main/java/org/example/baseresponse/ResponseStatus.java similarity index 73% rename from module-api/src/main/java/org/example/common/response/ResponseStatus.java rename to module-common/src/main/java/org/example/baseresponse/ResponseStatus.java index 4ef70ef34..fd3f48b21 100644 --- a/module-api/src/main/java/org/example/common/response/ResponseStatus.java +++ b/module-common/src/main/java/org/example/baseresponse/ResponseStatus.java @@ -1,4 +1,4 @@ -package org.example.common.response; +package org.example.baseresponse; public interface ResponseStatus { int getCode(); diff --git a/module-api/src/main/java/org/example/common/error/BaseErrorResponse.java b/module-common/src/main/java/org/example/baseresponse/error/BaseErrorResponse.java similarity index 89% rename from module-api/src/main/java/org/example/common/error/BaseErrorResponse.java rename to module-common/src/main/java/org/example/baseresponse/error/BaseErrorResponse.java index 1572423bb..5a8d998b0 100644 --- a/module-api/src/main/java/org/example/common/error/BaseErrorResponse.java +++ b/module-common/src/main/java/org/example/baseresponse/error/BaseErrorResponse.java @@ -1,9 +1,9 @@ -package org.example.common.error; +package org.example.baseresponse.error; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import lombok.Getter; import lombok.RequiredArgsConstructor; -import org.example.common.response.ResponseStatus; +import org.example.baseresponse.ResponseStatus; @Getter @RequiredArgsConstructor diff --git a/module-common/src/main/java/org/example/config/RateLimiterConfig.java b/module-common/src/main/java/org/example/config/RateLimiterConfig.java new file mode 100644 index 000000000..41a2e7d36 --- /dev/null +++ b/module-common/src/main/java/org/example/config/RateLimiterConfig.java @@ -0,0 +1,13 @@ +package org.example.config; + +import com.google.common.util.concurrent.RateLimiter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RateLimiterConfig { + @Bean + public RateLimiter rateLimiter() { + return RateLimiter.create(2.0); + } +} diff --git a/module-common/src/main/java/org/example/config/ReddisonConfig.java b/module-common/src/main/java/org/example/config/RedissonConfig.java similarity index 97% rename from module-common/src/main/java/org/example/config/ReddisonConfig.java rename to module-common/src/main/java/org/example/config/RedissonConfig.java index c1426178f..7d400286c 100644 --- a/module-common/src/main/java/org/example/config/ReddisonConfig.java +++ b/module-common/src/main/java/org/example/config/RedissonConfig.java @@ -10,7 +10,7 @@ import org.springframework.context.annotation.Configuration; @Configuration -public class ReddisonConfig { +public class RedissonConfig { @Bean public RedissonClient redissonClient() { diff --git a/module-common/src/main/java/org/example/config/RedissonLockUtil.java b/module-common/src/main/java/org/example/config/RedissonLockUtil.java new file mode 100644 index 000000000..815944d72 --- /dev/null +++ b/module-common/src/main/java/org/example/config/RedissonLockUtil.java @@ -0,0 +1,47 @@ +package org.example.config; + +import lombok.AllArgsConstructor; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +@Component +@AllArgsConstructor +public class RedissonLockUtil { + private final RedissonClient redissonClient; + + /** + * 분산락을 적용하여 함수 실행 + * @param lockKeys 락 키 값 (예: "lock:seat:1:A:5") + * @param waitTime 락 대기 시간 (초) + * @param leaseTime 락 유지 시간 (초) + * @param task 락을 걸고 실행할 함수 + * @return task 실행 결과 + */ + public T executeWithMultiLock(List lockKeys, int waitTime, int leaseTime, Supplier task) { + List locks = lockKeys.stream() + .map(redissonClient::getLock) + .toList(); + try { + boolean locked = redissonClient.getMultiLock(locks.toArray(new RLock[0])) + .tryLock(waitTime, leaseTime, TimeUnit.SECONDS); + if (!locked) { + throw new RuntimeException("락 획득 실패: " + lockKeys); + } + return task.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("락 처리 중 인터럽트 발생", e); + } finally { + for (RLock lock : locks) { + if (lock.isLocked() && lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } + } +} diff --git a/module-api/src/main/java/org/example/common/exception/BaseException.java b/module-common/src/main/java/org/example/exception/BaseException.java similarity index 76% rename from module-api/src/main/java/org/example/common/exception/BaseException.java rename to module-common/src/main/java/org/example/exception/BaseException.java index da8327fb1..7f788ff56 100644 --- a/module-api/src/main/java/org/example/common/exception/BaseException.java +++ b/module-common/src/main/java/org/example/exception/BaseException.java @@ -1,7 +1,7 @@ -package org.example.common.exception; +package org.example.exception; import lombok.Getter; -import org.example.common.response.ResponseStatus; +import org.example.baseresponse.ResponseStatus; @Getter public class BaseException extends RuntimeException { diff --git a/module-common/src/main/java/org/example/exception/RateLimitExceededException.java b/module-common/src/main/java/org/example/exception/RateLimitExceededException.java new file mode 100644 index 000000000..ebddbaa52 --- /dev/null +++ b/module-common/src/main/java/org/example/exception/RateLimitExceededException.java @@ -0,0 +1,9 @@ +package org.example.exception; + +import org.example.baseresponse.ResponseStatus; + +public class RateLimitExceededException extends BaseException { + public RateLimitExceededException(ResponseStatus exceptionStatus) { + super(exceptionStatus); + } +} diff --git a/module-api/src/main/java/org/example/common/exception/SeatException.java b/module-common/src/main/java/org/example/exception/SeatException.java similarity index 62% rename from module-api/src/main/java/org/example/common/exception/SeatException.java rename to module-common/src/main/java/org/example/exception/SeatException.java index 10635833d..7fc42dc5b 100644 --- a/module-api/src/main/java/org/example/common/exception/SeatException.java +++ b/module-common/src/main/java/org/example/exception/SeatException.java @@ -1,6 +1,6 @@ -package org.example.common.exception; +package org.example.exception; -import org.example.common.response.ResponseStatus; +import org.example.baseresponse.ResponseStatus; public class SeatException extends BaseException { public SeatException(ResponseStatus exceptionStatus) { diff --git a/module-api/src/main/java/org/example/common/handler/GlobalExceptionHandler.java b/module-common/src/main/java/org/example/exception/handler/GlobalExceptionHandler.java similarity index 77% rename from module-api/src/main/java/org/example/common/handler/GlobalExceptionHandler.java rename to module-common/src/main/java/org/example/exception/handler/GlobalExceptionHandler.java index 1ff42336c..5b4b943ac 100644 --- a/module-api/src/main/java/org/example/common/handler/GlobalExceptionHandler.java +++ b/module-common/src/main/java/org/example/exception/handler/GlobalExceptionHandler.java @@ -1,10 +1,13 @@ -package org.example.common.handler; +package org.example.exception.handler; import jakarta.validation.ConstraintViolationException; +import org.example.baseresponse.error.BaseErrorResponse; +import org.example.exception.RateLimitExceededException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; import java.util.HashMap; @@ -40,4 +43,10 @@ public ResponseEntity handleConstraintViolation(ConstraintViolationExcep .status(HttpStatus.BAD_REQUEST) .body(errors); } + + @ResponseStatus(HttpStatus.TOO_MANY_REQUESTS) + @ExceptionHandler(RateLimitExceededException.class) + public BaseErrorResponse handleRateLimitExceeded(RateLimitExceededException ex) { + return new BaseErrorResponse(ex.getExceptionStatus()); + } } diff --git a/module-api/src/main/java/org/example/common/handler/SeatExceptionControllerAdvice.java b/module-common/src/main/java/org/example/exception/handler/SeatExceptionControllerAdvice.java similarity index 81% rename from module-api/src/main/java/org/example/common/handler/SeatExceptionControllerAdvice.java rename to module-common/src/main/java/org/example/exception/handler/SeatExceptionControllerAdvice.java index 8915aa792..4b0f59dab 100644 --- a/module-api/src/main/java/org/example/common/handler/SeatExceptionControllerAdvice.java +++ b/module-common/src/main/java/org/example/exception/handler/SeatExceptionControllerAdvice.java @@ -1,8 +1,8 @@ -package org.example.common.handler; +package org.example.exception.handler; import lombok.extern.slf4j.Slf4j; -import org.example.common.error.BaseErrorResponse; -import org.example.common.exception.SeatException; +import org.example.baseresponse.error.BaseErrorResponse; +import org.example.exception.SeatException; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; diff --git a/module-domain/src/main/generated/org/example/domain/reservation/QReservation.java b/module-domain/src/main/generated/org/example/domain/reservation/QReservation.java index e9c6a2a45..c37e3793d 100644 --- a/module-domain/src/main/generated/org/example/domain/reservation/QReservation.java +++ b/module-domain/src/main/generated/org/example/domain/reservation/QReservation.java @@ -36,8 +36,6 @@ public class QReservation extends EntityPathBase { public final NumberPath screenScheduleId = createNumber("screenScheduleId", Long.class); - public final NumberPath seatId = createNumber("seatId", Long.class); - //inherited public final StringPath updatedBy = _super.updatedBy; diff --git a/module-domain/src/main/generated/org/example/domain/reservationseat/QReservationSeat.java b/module-domain/src/main/generated/org/example/domain/reservationseat/QReservationSeat.java new file mode 100644 index 000000000..2507f04ad --- /dev/null +++ b/module-domain/src/main/generated/org/example/domain/reservationseat/QReservationSeat.java @@ -0,0 +1,55 @@ +package org.example.domain.reservationseat; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QReservationSeat is a Querydsl query type for ReservationSeat + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QReservationSeat extends EntityPathBase { + + private static final long serialVersionUID = 652325848L; + + public static final QReservationSeat reservationSeat = new QReservationSeat("reservationSeat"); + + public final org.example.entity.QBaseEntity _super = new org.example.entity.QBaseEntity(this); + + //inherited + public final DateTimePath createdDate = _super.createdDate; + + public final NumberPath id = createNumber("id", Long.class); + + //inherited + public final StringPath lastModifiedBy = _super.lastModifiedBy; + + //inherited + public final DateTimePath lastModifiedDate = _super.lastModifiedDate; + + public final NumberPath reservationId = createNumber("reservationId", Long.class); + + public final NumberPath seatId = createNumber("seatId", Long.class); + + //inherited + public final StringPath updatedBy = _super.updatedBy; + + public QReservationSeat(String variable) { + super(ReservationSeat.class, forVariable(variable)); + } + + public QReservationSeat(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QReservationSeat(PathMetadata metadata) { + super(ReservationSeat.class, metadata); + } + +} + diff --git a/module-domain/src/main/generated/org/example/domain/screenRoom/QScreenRoom.java b/module-domain/src/main/generated/org/example/domain/screenroom/QScreenRoom.java similarity index 93% rename from module-domain/src/main/generated/org/example/domain/screenRoom/QScreenRoom.java rename to module-domain/src/main/generated/org/example/domain/screenroom/QScreenRoom.java index c3c6aada3..c3643054d 100644 --- a/module-domain/src/main/generated/org/example/domain/screenRoom/QScreenRoom.java +++ b/module-domain/src/main/generated/org/example/domain/screenroom/QScreenRoom.java @@ -1,4 +1,4 @@ -package org.example.domain.screenRoom; +package org.example.domain.screenroom; import static com.querydsl.core.types.PathMetadataFactory.*; @@ -15,7 +15,7 @@ @Generated("com.querydsl.codegen.DefaultEntitySerializer") public class QScreenRoom extends EntityPathBase { - private static final long serialVersionUID = -1107225198L; + private static final long serialVersionUID = -110207566L; public static final QScreenRoom screenRoom = new QScreenRoom("screenRoom"); diff --git a/module-domain/src/main/generated/org/example/domain/screenSchedule/QScreenSchedule.java b/module-domain/src/main/generated/org/example/domain/screenschedule/QScreenSchedule.java similarity index 94% rename from module-domain/src/main/generated/org/example/domain/screenSchedule/QScreenSchedule.java rename to module-domain/src/main/generated/org/example/domain/screenschedule/QScreenSchedule.java index 22a1281cb..368e34dff 100644 --- a/module-domain/src/main/generated/org/example/domain/screenSchedule/QScreenSchedule.java +++ b/module-domain/src/main/generated/org/example/domain/screenschedule/QScreenSchedule.java @@ -1,4 +1,4 @@ -package org.example.domain.screenSchedule; +package org.example.domain.screenschedule; import static com.querydsl.core.types.PathMetadataFactory.*; @@ -15,7 +15,7 @@ @Generated("com.querydsl.codegen.DefaultEntitySerializer") public class QScreenSchedule extends EntityPathBase { - private static final long serialVersionUID = 943941266L; + private static final long serialVersionUID = 2121698994L; public static final QScreenSchedule screenSchedule = new QScreenSchedule("screenSchedule"); diff --git a/module-domain/src/main/java/org/example/domain/reservation/Reservation.java b/module-domain/src/main/java/org/example/domain/reservation/Reservation.java index cdfc26801..243e7c911 100644 --- a/module-domain/src/main/java/org/example/domain/reservation/Reservation.java +++ b/module-domain/src/main/java/org/example/domain/reservation/Reservation.java @@ -29,21 +29,16 @@ public class Reservation extends BaseEntity { @Column(nullable = false) private Long screenScheduleId; - @Column(nullable = false) - private Long seatId; - @Builder - public Reservation(Long usersId, Long screenScheduleId, Long seatId) { + public Reservation(Long usersId, Long screenScheduleId) { this.usersId = usersId; this.screenScheduleId = screenScheduleId; - this.seatId = seatId; } - public static Reservation create(Long usersId, Long screenScheduleId, Long seatId) { + public static Reservation of(Long usersId, Long screenScheduleId) { return Reservation.builder() .usersId(usersId) .screenScheduleId(screenScheduleId) - .seatId(seatId) .build(); } } 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 new file mode 100644 index 000000000..ea2e86ebf --- /dev/null +++ b/module-domain/src/main/java/org/example/domain/reservationseat/ReservationSeat.java @@ -0,0 +1,38 @@ +package org.example.domain.reservationseat; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.example.domain.reservation.Reservation; +import org.example.entity.BaseEntity; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReservationSeat extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "reservation_seat_id", nullable = false) + private Long id; + + @Column(nullable = false) + private Long reservationId; + + @Column(nullable = false) + private Long seatId; + + @Builder + public ReservationSeat(Long reservationId, Long seatId) { + this.reservationId = reservationId; + this.seatId = seatId; + } + + public static ReservationSeat of(Long reservationId, Long seatId) { + return ReservationSeat.builder() + .reservationId(reservationId) + .seatId(seatId) + .build(); + } +} diff --git a/module-domain/src/main/java/org/example/domain/screenRoom/ScreenRoom.java b/module-domain/src/main/java/org/example/domain/screenroom/ScreenRoom.java similarity index 91% rename from module-domain/src/main/java/org/example/domain/screenRoom/ScreenRoom.java rename to module-domain/src/main/java/org/example/domain/screenroom/ScreenRoom.java index 8bb4a8fb6..512f04269 100644 --- a/module-domain/src/main/java/org/example/domain/screenRoom/ScreenRoom.java +++ b/module-domain/src/main/java/org/example/domain/screenroom/ScreenRoom.java @@ -1,4 +1,4 @@ -package org.example.domain.screenRoom; +package org.example.domain.screenroom; import jakarta.persistence.*; import lombok.AccessLevel; diff --git a/module-domain/src/main/java/org/example/domain/screenSchedule/ScreenSchedule.java b/module-domain/src/main/java/org/example/domain/screenschedule/ScreenSchedule.java similarity index 95% rename from module-domain/src/main/java/org/example/domain/screenSchedule/ScreenSchedule.java rename to module-domain/src/main/java/org/example/domain/screenschedule/ScreenSchedule.java index ffa63d918..c5ae9bf2e 100644 --- a/module-domain/src/main/java/org/example/domain/screenSchedule/ScreenSchedule.java +++ b/module-domain/src/main/java/org/example/domain/screenschedule/ScreenSchedule.java @@ -1,4 +1,4 @@ -package org.example.domain.screenSchedule; +package org.example.domain.screenschedule; import jakarta.persistence.*; import lombok.AccessLevel; 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 4b5b00349..eb8f698cb 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 @@ -4,12 +4,24 @@ import lombok.AccessLevel; 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; + +import java.util.Comparator; +import java.util.List; + +import static org.example.baseresponse.BaseResponseStatus.*; @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Slf4j public class Seat extends BaseEntity { + private static final int MAX_SEAT_COUNT = 5; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "seat_id", nullable = false) @@ -25,4 +37,57 @@ 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); + } + } + + public static void validateContinuousSeats(List seats) { + seats.sort(Comparator.comparing(seat -> seat.getCol().getColumn())); + + for (int i = 1; i < seats.size(); i++) { + SeatsDto prev = seats.get(i - 1); + SeatsDto current = seats.get(i); + + if (!prev.getRow().getRow().equals(current.getRow().getRow())) { + throw new SeatException(SEAT_ROW_DISCONTINUITY_ERROR); + } + + int prevCol = prev.getCol().getColumn(); + int currentCol = current.getCol().getColumn(); + if (currentCol != prevCol + 1) { + throw new SeatException(SEAT_COLUMN_DISCONTINUITY_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/dto/ReservedSeats.java b/module-domain/src/main/java/org/example/dto/SeatsDto.java similarity index 88% rename from module-domain/src/main/java/org/example/dto/ReservedSeats.java rename to module-domain/src/main/java/org/example/dto/SeatsDto.java index 0ae44a6e6..78ea46f2f 100644 --- a/module-domain/src/main/java/org/example/dto/ReservedSeats.java +++ b/module-domain/src/main/java/org/example/dto/SeatsDto.java @@ -7,7 +7,7 @@ @Getter @AllArgsConstructor -public class ReservedSeats { +public class SeatsDto { private Row row; private Col col; } diff --git a/module-domain/src/main/java/org/example/repository/ReservationJpaRepository.java b/module-domain/src/main/java/org/example/repository/ReservationJpaRepository.java index e25017f04..a029aa0ad 100644 --- a/module-domain/src/main/java/org/example/repository/ReservationJpaRepository.java +++ b/module-domain/src/main/java/org/example/repository/ReservationJpaRepository.java @@ -1,24 +1,7 @@ package org.example.repository; import org.example.domain.reservation.Reservation; -import org.example.dto.ReservedSeats; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.List; public interface ReservationJpaRepository extends JpaRepository { - @Query("select new org.example.dto.ReservedSeats(s.row, s.col) " + - "from Reservation r join Seat s on r.seatId=s.id " + - "where r.usersId=:userId and r.screenScheduleId=:screenScheduleId") - List findReservedSeatByUserIdAndScreenScheduleId(@Param("userId") Long userId, @Param("screenScheduleId") Long screenScheduleId); - - @Query("select s.id " + - "from Reservation r join Seat s on r.seatId=s.id " + - "where r.screenScheduleId=:screenScheduleId") - List findReservedSeatByScreenScheduleId(@Param("screenScheduleId") Long screenScheduleId); - - - boolean existsByUsersIdAndScreenScheduleIdAndSeatId(Long userId, Long screenScheduleId, Long seatId); } diff --git a/module-domain/src/main/java/org/example/repository/ReservationSeatRepository.java b/module-domain/src/main/java/org/example/repository/ReservationSeatRepository.java new file mode 100644 index 000000000..b5e616e5d --- /dev/null +++ b/module-domain/src/main/java/org/example/repository/ReservationSeatRepository.java @@ -0,0 +1,33 @@ +package org.example.repository; + +import jakarta.persistence.LockModeType; +import org.example.domain.reservationseat.ReservationSeat; +import org.example.dto.SeatsDto; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ReservationSeatRepository extends JpaRepository { + @Query("select new org.example.dto.SeatsDto(s.row, s.col) " + + "from ReservationSeat rs " + + "join Seat s on rs.seatId=s.id " + + "join Reservation r on rs.reservationId = r.id " + + "where r.usersId=:userId and r.screenScheduleId=:screenScheduleId") + List findReservedSeatByUserIdAndScreenScheduleId(@Param("userId") Long userId, @Param("screenScheduleId") Long screenScheduleId); + + @Query("select rs.id " + + "from ReservationSeat rs " + + "join Seat s on rs.seatId=s.id " + + "join Reservation r on rs.reservationId = r.id " + + "where r.screenScheduleId=:screenScheduleId") + List findReservedSeatByScreenScheduleId(@Param("screenScheduleId") Long screenScheduleId); + +// @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select rs from ReservationSeat rs join Reservation r on rs.reservationId=r.id " + + "where r.screenScheduleId=:screenScheduleId and rs.seatId = :seatId") + Optional findReservedSeatBySeatId(@Param("screenScheduleId") Long screenScheduleId, @Param("seatId") Long seatId); +} diff --git a/module-domain/src/main/java/org/example/repository/ScreenScheduleJpaRepository.java b/module-domain/src/main/java/org/example/repository/ScreenScheduleJpaRepository.java index 615f801be..5449f6ce9 100644 --- a/module-domain/src/main/java/org/example/repository/ScreenScheduleJpaRepository.java +++ b/module-domain/src/main/java/org/example/repository/ScreenScheduleJpaRepository.java @@ -1,6 +1,6 @@ package org.example.repository; -import org.example.domain.screenSchedule.ScreenSchedule; +import org.example.domain.screenschedule.ScreenSchedule; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; diff --git a/module-domain/src/main/java/org/example/repository/SeatJpaRepository.java b/module-domain/src/main/java/org/example/repository/SeatJpaRepository.java index ee4e46b74..9425c258f 100644 --- a/module-domain/src/main/java/org/example/repository/SeatJpaRepository.java +++ b/module-domain/src/main/java/org/example/repository/SeatJpaRepository.java @@ -1,20 +1,16 @@ package org.example.repository; -import jakarta.persistence.LockModeType; import org.example.domain.seat.Col; import org.example.domain.seat.Row; import org.example.domain.seat.Seat; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.Optional; public interface SeatJpaRepository extends JpaRepository { - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("select s from Seat s where s.screenRoomId=:screenRoomId and s.row=:row and s.col=:col " + - "and not exists (select 1 from Reservation r where r.seatId = s.id)") + @Query("select s from Seat s where s.screenRoomId=:screenRoomId and s.row=:row and s.col=:col") Optional findSeats(@Param("screenRoomId")Long screenRoomId, @Param("row") Row row, @Param("col") Col col);