From a743a66d00ae4a26cc3599998e92fdc33349b428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=86=8C=EB=AF=BC?= Date: Wed, 29 Jan 2025 21:42:08 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20pessimistic=20lock=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=84=B1=EA=B3=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/ReservationRequestDto.java | 2 +- ...ationSeat.java => ReservationSeatDto.java} | 2 +- .../example/service/ReservationService.java | 142 +++++++----------- .../example/ReservationConcurrencyTest.java | 16 +- .../example/baseresponse}/BaseResponse.java | 4 +- .../baseresponse}/BaseResponseStatus.java | 2 +- .../example/baseresponse}/ResponseStatus.java | 2 +- .../error/BaseErrorResponse.java | 4 +- .../org/example}/exception/BaseException.java | 4 +- .../org/example}/exception/SeatException.java | 4 +- .../handler/GlobalExceptionHandler.java | 2 +- .../SeatExceptionControllerAdvice.java | 6 +- .../domain/reservation/QReservation.java | 2 - .../reservationseat/QReservationSeat.java | 55 +++++++ .../QScreenRoom.java | 4 +- .../QScreenSchedule.java | 4 +- .../domain/reservation/Reservation.java | 9 +- .../reservationseat/ReservationSeat.java | 38 +++++ .../ScreenRoom.java | 2 +- .../ScreenSchedule.java | 2 +- .../java/org/example/domain/seat/Seat.java | 65 ++++++++ .../dto/{ReservedSeats.java => SeatsDto.java} | 2 +- .../repository/ReservationJpaRepository.java | 16 +- .../repository/ReservationSeatRepository.java | 33 ++++ .../ScreenScheduleJpaRepository.java | 2 +- .../example/repository/SeatJpaRepository.java | 2 +- 26 files changed, 281 insertions(+), 145 deletions(-) rename module-api/src/main/java/org/example/dto/request/{ReservationSeat.java => ReservationSeatDto.java} (91%) rename {module-api/src/main/java/org/example/common/response => module-common/src/main/java/org/example/baseresponse}/BaseResponse.java (88%) rename {module-api/src/main/java/org/example/common/response => module-common/src/main/java/org/example/baseresponse}/BaseResponseStatus.java (97%) rename {module-api/src/main/java/org/example/common/response => module-common/src/main/java/org/example/baseresponse}/ResponseStatus.java (73%) rename {module-api/src/main/java/org/example/common => module-common/src/main/java/org/example/baseresponse}/error/BaseErrorResponse.java (89%) rename {module-api/src/main/java/org/example/common => module-common/src/main/java/org/example}/exception/BaseException.java (76%) rename {module-api/src/main/java/org/example/common => module-common/src/main/java/org/example}/exception/SeatException.java (62%) rename {module-api/src/main/java/org/example/common => module-common/src/main/java/org/example/exception}/handler/GlobalExceptionHandler.java (97%) rename {module-api/src/main/java/org/example/common => module-common/src/main/java/org/example/exception}/handler/SeatExceptionControllerAdvice.java (81%) create mode 100644 module-domain/src/main/generated/org/example/domain/reservationseat/QReservationSeat.java rename module-domain/src/main/generated/org/example/domain/{screenRoom => screenroom}/QScreenRoom.java (93%) rename module-domain/src/main/generated/org/example/domain/{screenSchedule => screenschedule}/QScreenSchedule.java (94%) create mode 100644 module-domain/src/main/java/org/example/domain/reservationseat/ReservationSeat.java rename module-domain/src/main/java/org/example/domain/{screenRoom => screenroom}/ScreenRoom.java (91%) rename module-domain/src/main/java/org/example/domain/{screenSchedule => screenschedule}/ScreenSchedule.java (95%) rename module-domain/src/main/java/org/example/dto/{ReservedSeats.java => SeatsDto.java} (88%) create mode 100644 module-domain/src/main/java/org/example/repository/ReservationSeatRepository.java 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/ReservationService.java b/module-api/src/main/java/org/example/service/ReservationService.java index 103c38110..dcaad2934 100644 --- a/module-api/src/main/java/org/example/service/ReservationService.java +++ b/module-api/src/main/java/org/example/service/ReservationService.java @@ -3,145 +3,105 @@ import jakarta.persistence.OptimisticLockException; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.example.common.exception.SeatException; 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.dto.request.ReservationSeatDto; +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.Isolation; 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.*; @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; @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()); + // 예약하려는 좌석 검증 + validateSeats(reservationSeats); - try { - List seats = new ArrayList<>(); - for (ReservationSeat reservationSeat : reservationRequestDto.reservationSeats()) { - Row row = Row.valueOf(reservationSeat.row()); - Col col = Col.valueOf(reservationSeat.col()); + // 사용자가 동일한 상영에 대해 예약한 좌석 검증 + validateUserReserveSeats(reservationSeats, reservationRequestDto.usersId(), reservationRequestDto.screenScheduleId()); - Seat seat = seatJpaRepository.findSeats(screenRoomId, row, col) - .orElseThrow(() -> new SeatException(UNAVAILABLE_SEAT_ERROR)); + // 좌석에 락을 걸고 저장할 좌석들 반환 + List seats = validateReservedSeats(reservationRequestDto, reservationSeats); - seats.add(seat); + // 예약된 좌석인지 검증 + for (Seat seat : seats) { + boolean isReserved = reservationSeatRepository.findReservedSeatBySeatId(reservationRequestDto.screenScheduleId(), seat.getId()).isPresent(); + if (isReserved) { + throw new SeatException(ALREADY_RESERVED_SEAT_ERROR); } - saveReservation(reservationRequestDto.usersId(), reservationRequestDto.screenScheduleId(), seats); - } catch (OptimisticLockException e) { - throw new SeatException(CONCURRENT_RESERVATION_ERROR); } - } - - private void validateSeats(List seats) { - validateSeatCount(seats); - validateContinuousSeats(seats); - } - private void validateSeatCount(List seats) { - if (seats.size() > MAX_SEAT_COUNT) { - throw new SeatException(MAX_SEATS_EXCEEDED_ERROR); - } + Long reservationId = saveReservation(reservationRequestDto.usersId(), reservationRequestDto.screenScheduleId()); + saveReservationSeats(seats, reservationId); } - 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); - } - - 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); - } - } + private void validateSeats(List seats) { + Seat.validateCountExceeded(seats.size()); + Seat.validateContinuousSeats(seats); } - private void validateUserReserveSeats(List reservationSeats, Long userId, Long screenScheduleId) { - List reservedSeatsByUserId = reservationJpaRepository.findReservedSeatByUserIdAndScreenScheduleId(userId, screenScheduleId); - if (reservedSeatsByUserId.isEmpty()) { + private void validateUserReserveSeats(List reservationSeats, Long userId, Long screenScheduleId) { + List reservedSeats = reservationSeatRepository.findReservedSeatByUserIdAndScreenScheduleId(userId, screenScheduleId); + if (reservedSeats.isEmpty()) { return; } - isInMaxCount(reservationSeats, reservedSeatsByUserId); // 예약하려는 좌석이 5개 이상인지 - containsReservedSeat(reservationSeats, reservedSeatsByUserId); // 이미 예약된 좌석과 겹치는지 - isSameRow(reservationSeats, reservedSeatsByUserId); // 좌석이 같은 행에 있는지 - isContinuousCol(reservationSeats, reservedSeatsByUserId); // 좌석이 연속된 열인지 - } - - private void isInMaxCount(List reservationSeats, List reservedSeatsByUserId) { - if (reservationSeats.size() + reservedSeatsByUserId.size() > MAX_SEAT_COUNT) { - throw new SeatException(MAX_SEATS_EXCEEDED_ERROR); - } + Seat.validateCountExceeded(reservationSeats.size() + reservedSeats.size()); // 예약하려는 좌석이 5개 이상인지 + Seat.containsReservedSeat(reservationSeats, reservedSeats); // 이미 예약된 좌석과 겹치는지 + Seat.isSameRow(reservationSeats, reservedSeats); // 좌석이 같은 행에 있는지 + Seat.isContinuousCol(reservationSeats, reservedSeats); // 좌석이 연속된 열인지 } - 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 List validateReservedSeats(ReservationRequestDto reservationRequestDto, List reservationSeats) { + Long screenRoomId = screenScheduleJpaRepository.findScreenRoomIdById(reservationRequestDto.screenScheduleId()); + List seats = new ArrayList<>(); + for (SeatsDto reservationSeat : reservationSeats) { + Seat seat = seatJpaRepository.findSeats(screenRoomId, reservationSeat.getRow(), reservationSeat.getCol()) + .orElseThrow(() -> new SeatException(UNAVAILABLE_SEAT_ERROR)); - 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); - } + 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); } } } 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 97% 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..57b481755 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; 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-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-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 97% 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..4447bb51d 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,4 +1,4 @@ -package org.example.common.handler; +package org.example.exception.handler; import jakarta.validation.ConstraintViolationException; import org.springframework.http.HttpStatus; 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..a28cc628b 100644 --- a/module-domain/src/main/java/org/example/repository/ReservationJpaRepository.java +++ b/module-domain/src/main/java/org/example/repository/ReservationJpaRepository.java @@ -1,7 +1,7 @@ package org.example.repository; import org.example.domain.reservation.Reservation; -import org.example.dto.ReservedSeats; +import org.example.dto.SeatsDto; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -9,16 +9,6 @@ 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); + @Query("select r.id from Reservation r where r.screenScheduleId=:screenScheduleId") + Long findIdByScreenScheduleId(@Param("screenScheduleId") Long screenScheduleId); } 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..8d8e8e916 --- /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..3cdeb4d04 100644 --- a/module-domain/src/main/java/org/example/repository/SeatJpaRepository.java +++ b/module-domain/src/main/java/org/example/repository/SeatJpaRepository.java @@ -14,7 +14,7 @@ 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)") + "and not exists (select 1 from ReservationSeat rs where rs.seatId = s.id)") Optional findSeats(@Param("screenRoomId")Long screenRoomId, @Param("row") Row row, @Param("col") Col col); From f26fd2bf648cf0762b4f914155c0d8d46cef42d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=86=8C=EB=AF=BC?= Date: Wed, 29 Jan 2025 23:26:30 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20Seat=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=EC=9D=98=20pessimistic=20lock=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/org/example/service/ReservationService.java | 6 ++---- .../main/java/org/example/repository/SeatJpaRepository.java | 6 +----- 2 files changed, 3 insertions(+), 9 deletions(-) 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 dcaad2934..43af77ab7 100644 --- a/module-api/src/main/java/org/example/service/ReservationService.java +++ b/module-api/src/main/java/org/example/service/ReservationService.java @@ -1,6 +1,5 @@ package org.example.service; -import jakarta.persistence.OptimisticLockException; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.example.domain.reservation.Reservation; @@ -10,20 +9,19 @@ import org.example.domain.seat.Seat; import org.example.dto.SeatsDto; import org.example.dto.request.ReservationRequestDto; -import org.example.dto.request.ReservationSeatDto; 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.Isolation; import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.List; -import static org.example.baseresponse.BaseResponseStatus.*; +import static org.example.baseresponse.BaseResponseStatus.ALREADY_RESERVED_SEAT_ERROR; +import static org.example.baseresponse.BaseResponseStatus.UNAVAILABLE_SEAT_ERROR; @Slf4j @Service 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 3cdeb4d04..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 ReservationSeat rs where rs.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); From a4bbba453a01ec9cae8bd1fc7341930fe6c10a9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=86=8C=EB=AF=BC?= Date: Thu, 30 Jan 2025 02:26:17 +0900 Subject: [PATCH 3/8] =?UTF-8?q?rename:=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/config/{ReddisonConfig.java => RedissonConfig.java} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename module-common/src/main/java/org/example/config/{ReddisonConfig.java => RedissonConfig.java} (97%) 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() { From 7d1be8ac421d1fdee976ebc3f4fa1a914e1f46d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=86=8C=EB=AF=BC?= Date: Thu, 30 Jan 2025 02:26:36 +0900 Subject: [PATCH 4/8] =?UTF-8?q?refactor:=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=BF=BC=EB=A6=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/example/repository/ReservationJpaRepository.java | 7 ------- 1 file changed, 7 deletions(-) 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 a28cc628b..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,14 +1,7 @@ package org.example.repository; import org.example.domain.reservation.Reservation; -import org.example.dto.SeatsDto; 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 r.id from Reservation r where r.screenScheduleId=:screenScheduleId") - Long findIdByScreenScheduleId(@Param("screenScheduleId") Long screenScheduleId); } From 8f5db338918d789321c15acb8d224ec7e3b85805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=86=8C=EB=AF=BC?= Date: Thu, 30 Jan 2025 03:10:20 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=ED=95=A8=EC=88=98=ED=98=95=20?= =?UTF-8?q?=EB=B6=84=EC=82=B0=EB=9D=BD=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/service/ReservationService.java | 28 ++++++++--- .../org/example/config/RedissonLockUtil.java | 47 +++++++++++++++++++ .../repository/ReservationSeatRepository.java | 2 +- 3 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 module-common/src/main/java/org/example/config/RedissonLockUtil.java 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 43af77ab7..7a6128e13 100644 --- a/module-api/src/main/java/org/example/service/ReservationService.java +++ b/module-api/src/main/java/org/example/service/ReservationService.java @@ -1,7 +1,9 @@ package org.example.service; +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; @@ -15,7 +17,6 @@ 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.List; @@ -31,8 +32,8 @@ public class ReservationService { private final SeatJpaRepository seatJpaRepository; private final ScreenScheduleJpaRepository screenScheduleJpaRepository; private final ReservationSeatRepository reservationSeatRepository; + private final RedissonLockUtil redissonLockUtil; - @Transactional public void reserveMovie(ReservationRequestDto reservationRequestDto) { List reservationSeats = new ArrayList<>( reservationRequestDto.reservationSeats().stream() @@ -40,14 +41,30 @@ public void reserveMovie(ReservationRequestDto reservationRequestDto) { .toList() ); + Long screenRoomId = screenScheduleJpaRepository.findScreenRoomIdById(reservationRequestDto.screenScheduleId()); + // 예약하려는 좌석 검증 validateSeats(reservationSeats); // 사용자가 동일한 상영에 대해 예약한 좌석 검증 validateUserReserveSeats(reservationSeats, reservationRequestDto.usersId(), reservationRequestDto.screenScheduleId()); + // 개별 좌석별 락 키 생성 + List lockKeys = reservationRequestDto.reservationSeats().stream() + .map(seat -> "lock:seat:" + screenRoomId + ":" + seat.row() + ":" + seat.col()) + .toList(); + + // Redisson MultiLock 적용 (여러 개의 좌석을 동시에 보호) + redissonLockUtil.executeWithMultiLock(lockKeys, 5, 10, () -> { + saveReservationWithTransaction(reservationRequestDto, reservationSeats, screenRoomId); + return null; + }); + } + + @Transactional + public void saveReservationWithTransaction(ReservationRequestDto reservationRequestDto, List reservationSeats, Long screenRoomId) { // 좌석에 락을 걸고 저장할 좌석들 반환 - List seats = validateReservedSeats(reservationRequestDto, reservationSeats); + List seats = validateReservedSeats(screenRoomId, reservationSeats); // 예약된 좌석인지 검증 for (Seat seat : seats) { @@ -78,8 +95,7 @@ private void validateUserReserveSeats(List reservationSeats, Long user Seat.isContinuousCol(reservationSeats, reservedSeats); // 좌석이 연속된 열인지 } - private List validateReservedSeats(ReservationRequestDto reservationRequestDto, List reservationSeats) { - Long screenRoomId = screenScheduleJpaRepository.findScreenRoomIdById(reservationRequestDto.screenScheduleId()); + private List validateReservedSeats(Long screenRoomId, List reservationSeats) { List seats = new ArrayList<>(); for (SeatsDto reservationSeat : reservationSeats) { Seat seat = seatJpaRepository.findSeats(screenRoomId, reservationSeat.getRow(), reservationSeat.getCol()) @@ -102,4 +118,4 @@ private void saveReservationSeats(List seats, Long reservationId) { reservationSeatRepository.save(reservationSeat); } } -} +} \ No newline at end of file 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-domain/src/main/java/org/example/repository/ReservationSeatRepository.java b/module-domain/src/main/java/org/example/repository/ReservationSeatRepository.java index 8d8e8e916..b5e616e5d 100644 --- a/module-domain/src/main/java/org/example/repository/ReservationSeatRepository.java +++ b/module-domain/src/main/java/org/example/repository/ReservationSeatRepository.java @@ -26,7 +26,7 @@ public interface ReservationSeatRepository extends JpaRepository findReservedSeatByScreenScheduleId(@Param("screenScheduleId") Long screenScheduleId); - @Lock(LockModeType.PESSIMISTIC_WRITE) +// @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); From c03a112eebefb2cbab28af76ddcaf1a150f16df0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=86=8C=EB=AF=BC?= Date: Sun, 2 Feb 2025 23:40:02 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20guava=20ratelimiter=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 ++ .../example/controller/MovieController.java | 5 +- .../controller/ReservationController.java | 9 ++-- .../service/movie/FindMovieService.java | 10 +++- .../example/service/movie/MovieService.java | 24 ++++++--- module-api/src/main/resources/application.yml | 2 +- .../java/org/example/ApiApplicationTest.java | 52 +++++++++++++++++-- .../org/example/GuavaRateLimiterTest.java | 47 +++++++++++++++++ .../baseresponse/BaseResponseStatus.java | 4 +- .../org/example/config/RateLimiterConfig.java | 13 +++++ .../exception/RateLimitExceededException.java | 9 ++++ .../handler/GlobalExceptionHandler.java | 8 +++ 12 files changed, 166 insertions(+), 20 deletions(-) create mode 100644 module-api/src/test/java/org/example/GuavaRateLimiterTest.java create mode 100644 module-common/src/main/java/org/example/config/RateLimiterConfig.java create mode 100644 module-common/src/main/java/org/example/exception/RateLimitExceededException.java 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..bb6b3fc0e 100644 --- a/module-api/src/main/java/org/example/controller/MovieController.java +++ b/module-api/src/main/java/org/example/controller/MovieController.java @@ -2,6 +2,7 @@ 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.service.movie.MovieService; @@ -20,8 +21,8 @@ public class MovieController { private final MovieService movieService; @GetMapping("/movies/playing") - public ResponseEntity> getPlayingMovies(@ModelAttribute @Validated MoviesFilterRequestDto moviesFilterRequestDto) { + public BaseResponse> getPlayingMovies(@ModelAttribute @Validated MoviesFilterRequestDto moviesFilterRequestDto) { List playingMovies = movieService.getPlayingMovies(moviesFilterRequestDto); - return ResponseEntity.ok(playingMovies); + return new BaseResponse<>(playingMovies); } } 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/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..d2cb386c1 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..5579eccf9 100644 --- a/module-api/src/test/java/org/example/ApiApplicationTest.java +++ b/module-api/src/test/java/org/example/ApiApplicationTest.java @@ -1,12 +1,58 @@ package org.example; +import com.google.common.util.concurrent.RateLimiter; +import org.example.repository.MovieJpaRepository; +import org.example.service.movie.FindMovieService; +import org.example.service.movie.MovieService; +import org.junit.jupiter.api.Assertions; 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.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.servlet.MockMvc; -@SpringBootTest +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc class ApiApplicationTest { + @LocalServerPort + private int port; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private MovieService movieService; + + @Autowired + private FindMovieService findMovieService; + + @Autowired + private RateLimiter rateLimiter; + + @Autowired + private MovieJpaRepository movieJpaRepository; + + @Autowired + private TestRestTemplate restTemplate; + @Test - void contextLoads() { + void testRateLimiter_BlocksExcessRequests() throws Exception { + String url = "http://localhost:" + port + "/movies/playing"; + + // 첫 번째 요청 + ResponseEntity response1 = restTemplate.getForEntity(url, String.class); + Assertions.assertEquals(HttpStatus.OK, response1.getStatusCode()); + + // 두 번째 요청 + ResponseEntity response2 = restTemplate.getForEntity(url, String.class); + Assertions.assertEquals(HttpStatus.OK, response2.getStatusCode()); + + // 세 번째 요청 + ResponseEntity response3 = restTemplate.getForEntity(url, String.class); + Assertions.assertEquals(HttpStatus.TOO_MANY_REQUESTS, response3.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-common/src/main/java/org/example/baseresponse/BaseResponseStatus.java b/module-common/src/main/java/org/example/baseresponse/BaseResponseStatus.java index 57b481755..91b2694e6 100644 --- a/module-common/src/main/java/org/example/baseresponse/BaseResponseStatus.java +++ b/module-common/src/main/java/org/example/baseresponse/BaseResponseStatus.java @@ -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-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/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-common/src/main/java/org/example/exception/handler/GlobalExceptionHandler.java b/module-common/src/main/java/org/example/exception/handler/GlobalExceptionHandler.java index 4447bb51d..1b378b9aa 100644 --- a/module-common/src/main/java/org/example/exception/handler/GlobalExceptionHandler.java +++ b/module-common/src/main/java/org/example/exception/handler/GlobalExceptionHandler.java @@ -1,10 +1,13 @@ 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,9 @@ public ResponseEntity handleConstraintViolation(ConstraintViolationExcep .status(HttpStatus.BAD_REQUEST) .body(errors); } + + @ExceptionHandler(RateLimitExceededException.class) + public BaseErrorResponse handleRateLimitExceeded(RateLimitExceededException ex) { + return new BaseErrorResponse(ex.getExceptionStatus()); + } } From 97fe8ec99a5f6683fb57efdec6cd537176b93cfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=86=8C=EB=AF=BC?= Date: Mon, 3 Feb 2025 03:41:04 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20=EC=A1=B0=ED=9A=8C=20API=EC=97=90?= =?UTF-8?q?=20Redisson=20RateLimit=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/controller/MovieController.java | 31 ++++- .../service/RedisRateLimiterService.java | 43 +++++++ .../example/service/movie/MovieService.java | 6 +- .../java/org/example/ApiApplicationTest.java | 106 +++++++++++++----- .../handler/GlobalExceptionHandler.java | 1 + 5 files changed, 155 insertions(+), 32 deletions(-) create mode 100644 module-api/src/main/java/org/example/service/RedisRateLimiterService.java 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 bb6b3fc0e..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,12 +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; @@ -14,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 BaseResponse> 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 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/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/movie/MovieService.java b/module-api/src/main/java/org/example/service/movie/MovieService.java index d2cb386c1..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 @@ -25,9 +25,9 @@ public class MovieService { public final RateLimiter rateLimiter; public List getPlayingMovies(MoviesFilterRequestDto moviesFilterRequestDto) { - if (!rateLimiter.tryAcquire()) { - throw new RateLimitExceededException(TOO_MANY_REQUEST_ERROR); - } +// if (!rateLimiter.tryAcquire()) { +// throw new RateLimitExceededException(TOO_MANY_REQUEST_ERROR); +// } List movieScreeningInfos = findMovieService.getPlayingMovies(moviesFilterRequestDto).getMovieScreeningInfos() diff --git a/module-api/src/test/java/org/example/ApiApplicationTest.java b/module-api/src/test/java/org/example/ApiApplicationTest.java index 5579eccf9..d213b6416 100644 --- a/module-api/src/test/java/org/example/ApiApplicationTest.java +++ b/module-api/src/test/java/org/example/ApiApplicationTest.java @@ -1,19 +1,14 @@ package org.example; -import com.google.common.util.concurrent.RateLimiter; -import org.example.repository.MovieJpaRepository; -import org.example.service.movie.FindMovieService; -import org.example.service.movie.MovieService; -import org.junit.jupiter.api.Assertions; 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.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.test.web.servlet.MockMvc; +import org.springframework.http.*; + +import static org.junit.jupiter.api.Assertions.assertEquals; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureMockMvc @@ -21,38 +16,95 @@ class ApiApplicationTest { @LocalServerPort private int port; - @Autowired - private MockMvc mockMvc; - - @Autowired - private MovieService movieService; - - @Autowired - private FindMovieService findMovieService; - - @Autowired - private RateLimiter rateLimiter; - - @Autowired - private MovieJpaRepository movieJpaRepository; - @Autowired private TestRestTemplate restTemplate; + private static final String TEST_IP = "127.0.0.1"; + @Test - void testRateLimiter_BlocksExcessRequests() throws Exception { + void testGuavaRateLimiter_BlocksExcessRequests() throws Exception { String url = "http://localhost:" + port + "/movies/playing"; // 첫 번째 요청 ResponseEntity response1 = restTemplate.getForEntity(url, String.class); - Assertions.assertEquals(HttpStatus.OK, response1.getStatusCode()); + assertEquals(HttpStatus.OK, response1.getStatusCode()); // 두 번째 요청 ResponseEntity response2 = restTemplate.getForEntity(url, String.class); - Assertions.assertEquals(HttpStatus.OK, response2.getStatusCode()); + assertEquals(HttpStatus.OK, response2.getStatusCode()); // 세 번째 요청 ResponseEntity response3 = restTemplate.getForEntity(url, String.class); - Assertions.assertEquals(HttpStatus.TOO_MANY_REQUESTS, response3.getStatusCode()); + 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-common/src/main/java/org/example/exception/handler/GlobalExceptionHandler.java b/module-common/src/main/java/org/example/exception/handler/GlobalExceptionHandler.java index 1b378b9aa..5b4b943ac 100644 --- a/module-common/src/main/java/org/example/exception/handler/GlobalExceptionHandler.java +++ b/module-common/src/main/java/org/example/exception/handler/GlobalExceptionHandler.java @@ -44,6 +44,7 @@ public ResponseEntity handleConstraintViolation(ConstraintViolationExcep .body(errors); } + @ResponseStatus(HttpStatus.TOO_MANY_REQUESTS) @ExceptionHandler(RateLimitExceededException.class) public BaseErrorResponse handleRateLimitExceeded(RateLimitExceededException ex) { return new BaseErrorResponse(ex.getExceptionStatus()); From 9f15018d9f468ea3ad241ed01700d9ee8775a79a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=86=8C=EB=AF=BC?= Date: Mon, 3 Feb 2025 16:12:28 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=EB=B6=84=EC=82=B0=EB=9D=BD=20?= =?UTF-8?q?=ED=82=A4=20screenRoomId=20->=20screenScheduleId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/org/example/service/ReservationService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7a6128e13..e372f981e 100644 --- a/module-api/src/main/java/org/example/service/ReservationService.java +++ b/module-api/src/main/java/org/example/service/ReservationService.java @@ -51,7 +51,7 @@ public void reserveMovie(ReservationRequestDto reservationRequestDto) { // 개별 좌석별 락 키 생성 List lockKeys = reservationRequestDto.reservationSeats().stream() - .map(seat -> "lock:seat:" + screenRoomId + ":" + seat.row() + ":" + seat.col()) + .map(seat -> "lock:seat:" + reservationRequestDto.screenScheduleId() + ":" + seat.row() + ":" + seat.col()) .toList(); // Redisson MultiLock 적용 (여러 개의 좌석을 동시에 보호)