From f5c50932e656673b680f2035b130199ac2234ec9 Mon Sep 17 00:00:00 2001 From: soyoungcareer Date: Thu, 30 Jan 2025 21:22:36 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor:=203=EC=A3=BC=EC=B0=A8=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 16 ++ .../adapter/controller/MovieController.java | 2 - .../adapter/controller/TicketController.java | 15 +- .../application/dto/MovieRequestDTO.java | 2 - .../application/dto/TicketRequestDTO.java | 2 - .../application/service/MovieService.java | 2 +- .../application/service/TicketService.java | 203 +++++++++--------- cinema-common/build.gradle | 1 + .../exception/GlobalExceptionHandler.java | 41 ++-- .../com/cinema/core/domain/Screening.java | 2 - .../java/com/cinema/core/domain/Seat.java | 2 - .../java/com/cinema/core/domain/Theater.java | 2 - .../java/com/cinema/core/domain/Ticket.java | 9 +- .../com/cinema/core/domain/TicketSeat.java | 2 - .../java/com/cinema/core/domain/User.java | 2 - .../cinema/infra/dto/MovieScreeningData.java | 1 - .../com/cinema/infra/dto/ScreeningData.java | 2 - .../infra/repository/UserRepository.java | 9 + 18 files changed, 162 insertions(+), 153 deletions(-) create mode 100644 cinema-infra/src/main/java/com/cinema/infra/repository/UserRepository.java diff --git a/README.md b/README.md index 6e489bd84..c8417a946 100644 --- a/README.md +++ b/README.md @@ -546,6 +546,8 @@ Querydsl로 변경하여 한 번에 조회할 수 있도록 변경함. > 동시성 제어를 위해 *좌석 점유와 결제 이벤트를 분리*한 것으로 보임. => 궁금한 점 : 보통 예매 시스템에서 좌석을 누르는 순간 바로 점유 여부를 체크하는지 아니면 좌석을 누른 후 선택하기 버튼을 추가로 눌렀을 때 점유 여부를 체크하는지? +> 상황에 따라 다르게 적용하지만 유저 입장에서는 빠르게 점유 여부를 체크할 수 있는 편이 좋음. +> 다만, 빠르게 점유 여부를 체크할수록 그만큼 요청이 많아지므로 리소스가 많이 들게 됨. 회사의 리소스 가용여부에 따라 효율적 판단 요구됨. ### Pessimistic Lock (비관적 락) - 트랜잭션이 시작될 때 해당 행을 잠금 처리하고, 다른 트랜잭션이 해당 데이터를 읽거나 수정하지 못하도록 막는 방식. @@ -567,6 +569,20 @@ Querydsl로 변경하여 한 번에 조회할 수 있도록 변경함. > waitTime 을 5초로 설정한 것은 사용자가 너무 길게 느껴지지 않는 시간이면서 서버 처리시간에 여유를 주었고, > leaseTime 보다 살짝 길게 설정하여 데이터 정합성이 유지될 수 있도록 하였음. +--- + +## [3주차] 피드백 후 수정사항 +1. ✅ 예외 처리는 try-catch보다는 Exception Handler에서 처리하는 것이 책임 구분이 명확해짐. +2. ✅ DTO에 Setter와 같은 과도한 어노테이션 적용을 지양하고 해당 객체에 적절한 책임을 줄 것 + - 절차지향적인 방법이 아닌 객체지향적 방법으로 개선할 것 +3. ✅ 반복문에서 size() 메서드를 호출하는 것은 성능상 좋지 않음. +4. ✅ for문이나 배열의 인덱스 조회와 같은 부분은 가독성에 좋지 않고 런타임시 예외가 발생할 수 있음. +5. ✅ 락을 획득하지 않은 요청도 모두 Transactional을 적용받고 있으므로 함수형 락을 사용하는 이점을 살리기 위해 분리할 것 +6. ✅ 클라이언트에서 전달받은 user 값에 대한 검증 추가 + + +--- +# [4주차] 서버 안정성을 높이기 위한 RateLimit 구현 diff --git a/cinema-adapter/src/main/java/com/cinema/adapter/controller/MovieController.java b/cinema-adapter/src/main/java/com/cinema/adapter/controller/MovieController.java index e2d687177..d25bb7131 100644 --- a/cinema-adapter/src/main/java/com/cinema/adapter/controller/MovieController.java +++ b/cinema-adapter/src/main/java/com/cinema/adapter/controller/MovieController.java @@ -2,11 +2,9 @@ import com.cinema.application.dto.MovieRequestDTO; import com.cinema.application.dto.MovieResponseDTO; -import com.cinema.application.dto.TicketRequestDTO; import com.cinema.application.service.MovieService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; diff --git a/cinema-adapter/src/main/java/com/cinema/adapter/controller/TicketController.java b/cinema-adapter/src/main/java/com/cinema/adapter/controller/TicketController.java index d1c40decf..f522f7001 100644 --- a/cinema-adapter/src/main/java/com/cinema/adapter/controller/TicketController.java +++ b/cinema-adapter/src/main/java/com/cinema/adapter/controller/TicketController.java @@ -1,17 +1,12 @@ package com.cinema.adapter.controller; -import com.cinema.application.dto.MovieRequestDTO; -import com.cinema.application.dto.MovieResponseDTO; import com.cinema.application.dto.TicketRequestDTO; -import com.cinema.application.service.MovieService; import com.cinema.application.service.TicketService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.List; - @RequiredArgsConstructor @RestController @RequestMapping("/api/v1/tickets") @@ -23,14 +18,8 @@ public class TicketController { * */ @PostMapping public ResponseEntity bookTickets(@Valid @RequestBody TicketRequestDTO ticketRequestDTO ) { - try { - ticketService.bookTickets(ticketRequestDTO); - return ResponseEntity.ok("예매가 성공적으로 완료되었습니다."); - } catch (Exception e) { - return ResponseEntity.badRequest().body(e.getMessage()); - } + ticketService.bookTickets(ticketRequestDTO); + return ResponseEntity.ok("예매가 성공적으로 완료되었습니다."); } - - } diff --git a/cinema-application/src/main/java/com/cinema/application/dto/MovieRequestDTO.java b/cinema-application/src/main/java/com/cinema/application/dto/MovieRequestDTO.java index dd4b19254..b8cb1abcf 100644 --- a/cinema-application/src/main/java/com/cinema/application/dto/MovieRequestDTO.java +++ b/cinema-application/src/main/java/com/cinema/application/dto/MovieRequestDTO.java @@ -2,10 +2,8 @@ import jakarta.validation.constraints.*; import lombok.Getter; -import lombok.Setter; @Getter -@Setter public class MovieRequestDTO { @Size(max=100, message="제목은 100자 이하로 입력해야 합니다.") @Pattern(regexp = "^[a-zA-Z0-9가-힣]*$", message = "특수문자는 허용되지 않습니다.") diff --git a/cinema-application/src/main/java/com/cinema/application/dto/TicketRequestDTO.java b/cinema-application/src/main/java/com/cinema/application/dto/TicketRequestDTO.java index 2fa1f3f74..fddfcbd99 100644 --- a/cinema-application/src/main/java/com/cinema/application/dto/TicketRequestDTO.java +++ b/cinema-application/src/main/java/com/cinema/application/dto/TicketRequestDTO.java @@ -2,12 +2,10 @@ import jakarta.validation.constraints.*; import lombok.Getter; -import lombok.Setter; import java.util.List; @Getter -@Setter public class TicketRequestDTO { @NotNull private Long screeningId; diff --git a/cinema-application/src/main/java/com/cinema/application/service/MovieService.java b/cinema-application/src/main/java/com/cinema/application/service/MovieService.java index 60cedb575..5983f352b 100644 --- a/cinema-application/src/main/java/com/cinema/application/service/MovieService.java +++ b/cinema-application/src/main/java/com/cinema/application/service/MovieService.java @@ -28,7 +28,7 @@ public class MovieService { /** * 영화별 상영시간표 조회 * */ - // TODO : 캐시 전략 변경해볼 것! + // TODO : 캐시 전략 변경해볼 것! (전체 데이터 캐싱 후 필터링하는 방식?) @Cacheable( value = "movieScreenings", key = "(#title ?: '').concat('_').concat(#genreCd ?: '')", diff --git a/cinema-application/src/main/java/com/cinema/application/service/TicketService.java b/cinema-application/src/main/java/com/cinema/application/service/TicketService.java index 035f78c2f..0a3fa43dc 100644 --- a/cinema-application/src/main/java/com/cinema/application/service/TicketService.java +++ b/cinema-application/src/main/java/com/cinema/application/service/TicketService.java @@ -2,27 +2,26 @@ import com.cinema.application.dto.TicketRequestDTO; import com.cinema.common.enums.SeatNameCode; -import com.cinema.core.annotation.DistributedLock; import com.cinema.core.domain.Ticket; import com.cinema.core.domain.TicketSeat; import com.cinema.infra.lock.DistributedLockUtil; import com.cinema.infra.repository.SeatRepository; import com.cinema.infra.repository.TicketRepository; import com.cinema.infra.repository.TicketSeatRepository; -import jakarta.persistence.PessimisticLockException; -import jakarta.transaction.Transactional; +import com.cinema.infra.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Value; -import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.Iterator; import java.util.List; -import java.util.concurrent.TimeUnit; +import java.util.NoSuchElementException; import java.util.stream.Collectors; @Service @@ -32,6 +31,8 @@ public class TicketService { private final TicketRepository ticketRepository; private final TicketSeatRepository ticketSeatRepository; private final SeatRepository seatRepository; + private final UserRepository userRepository; + private final RedissonClient redissonClient; private final DistributedLockUtil lockUtil; @@ -41,127 +42,125 @@ public class TicketService { /** * 예매하기 * */ - // FIXME : AOP Distibuted Lock -// @DistributedLock(key = "lock:screening:#{#ticketRequestDTO.screeningId}") - - // FIXME : Optimistic Lock - @Retryable(value = ObjectOptimisticLockingFailureException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000)) - @Transactional + @Retryable(value = OptimisticLockingFailureException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000)) public void bookTickets(TicketRequestDTO ticketRequestDTO) { - // FIXME : 명령형 Distibuted Lock -// String lockKey = "lock:screening:" + ticketRequestDTO.getScreeningId(); -// RLock lock = redissonClient.getLock(lockKey); - - // FIXME : 함수형 Distibuted Lock - String lockKey = "lock:screening:" + ticketRequestDTO.getScreeningId(); - -// try { - // FIXME : 명령형 Distibuted Lock - // tyrLock(waitTime 락 대기 시간, leaseTime 락 유지 시간, 락 획득 실패 시) -// if (!lock.tryLock(5, 3, TimeUnit.SECONDS)) { -// throw new IllegalStateException("동일 좌석이 이미 예약 중입니다. 나중에 다시 시도해 주세요."); -// } - - // FIXME : 함수형 Distibuted Lock - lockUtil.executeWithLock(lockKey, 5, 3, () -> { - List seatNameEnums = ticketRequestDTO.getSeatNames().stream() - .map(SeatNameCode::fromString) - .collect(Collectors.toList()); + List seatNameEnums = ticketRequestDTO.getSeatNames().stream() + .map(SeatNameCode::fromString) + .collect(Collectors.toList()); - if (seatNameEnums == null || seatNameEnums.isEmpty()) { - throw new NullPointerException("좌석 정보가 없습니다."); - } + // 사용자 확인 + if (!this.isUserExists(ticketRequestDTO.getUserId())) { + throw new NoSuchElementException("사용자 정보가 없습니다. ID : " + ticketRequestDTO.getUserId()); + } - // 좌석 예약 가능 여부 확인 - if (!this.areSeatsBookable(ticketRequestDTO.getScreeningId(), seatNameEnums)) { - throw new IllegalStateException("선택한 좌석 중 예약이 불가능한 좌석이 있습니다."); - } + // 좌석 예약 가능 여부 확인 + if (!this.areSeatsBookable(ticketRequestDTO.getScreeningId(), seatNameEnums)) { + throw new IllegalStateException("선택한 좌석 중 예약이 불가능한 좌석이 있습니다."); + } - // 좌석 연속 여부 확인 (동일한 라인인지) - if (!this.areSeatsConsecutive(seatNameEnums)) { - throw new IllegalStateException("좌석은 동일한 행이면서 연속적이어야 합니다."); - } + // 좌석 연속 여부 확인 (동일한 라인인지) + if (!this.areSeatsConsecutive(seatNameEnums)) { + throw new IllegalStateException("좌석은 동일한 행이면서 연속적이어야 합니다."); + } - // 상영시간표별 최대 5개 좌석 예약 가능 - if (!this.isBookingExceed(ticketRequestDTO, seatNameEnums.size())) { - throw new IllegalStateException("상영시간표당 예매 가능 좌석 수를 초과하였습니다."); - } + // 상영시간표별 최대 5개 좌석 예약 가능 + if (!this.isBookingExceed(ticketRequestDTO, seatNameEnums.size())) { + throw new IllegalStateException("상영시간표당 예매 가능 좌석 수를 초과하였습니다."); + } - // 예매 저장 - Ticket ticket = new Ticket(); - ticket.setUserId(ticketRequestDTO.getUserId()); - ticket.setScreeningId(ticketRequestDTO.getScreeningId()); - Ticket savedTicket = ticketRepository.save(ticket); + // lock을 획득한 요청에 대해서만 Transactional 적용하기 위해 분리 + String lockKey = "lock:screening:" + ticketRequestDTO.getScreeningId(); + lockUtil.executeWithLock(lockKey, 5, 3, () -> { + bookTicketsWithTransaction(ticketRequestDTO, seatNameEnums); + return null; + }); + } - // 예매 좌석 저장 - List ticketSeats = seatNameEnums.stream() - .map(seat -> { - Long seatId = seatRepository.findSeatIdByScreeningIdAndSeatNameCd( - ticketRequestDTO.getScreeningId(), seat.name() - ).orElseThrow(() -> new IllegalArgumentException("좌석 정보를 찾을 수 없습니다: " + seat.name())); + /** + * 예매 정보 저장 + * */ + @Transactional + public void bookTicketsWithTransaction(TicketRequestDTO ticketRequestDTO, List seatNameEnums) { + // 예매 저장 + Ticket ticket = Ticket.builder() + .userId(ticketRequestDTO.getUserId()) + .screeningId(ticketRequestDTO.getScreeningId()) + .build(); + Ticket savedTicket = ticketRepository.save(ticket); + ticketRepository.flush(); + + // 예매 좌석 저장 + List ticketSeats = seatNameEnums.stream() + .map(seat -> { + Long seatId = seatRepository.findSeatIdByScreeningIdAndSeatNameCd( + ticketRequestDTO.getScreeningId(), seat.name() + ).orElseThrow(() -> new NoSuchElementException("좌석 정보를 찾을 수 없습니다. 상영시간표ID : " + ticketRequestDTO.getScreeningId() + ", 좌석명 : " + seat.name())); - return new TicketSeat(savedTicket.getTicketId(), seatId); - }) - .collect(Collectors.toList()); + return new TicketSeat(savedTicket.getTicketId(), seatId); + }) + .collect(Collectors.toList()); - ticketSeatRepository.saveAll(ticketSeats); + ticketSeatRepository.saveAll(ticketSeats); + ticketSeatRepository.flush(); + } - return null; - }); + /** + * 사용자 확인 + * */ + private boolean isUserExists(Long userId) { + if (userId == null) { + throw new NullPointerException("사용자 정보가 없습니다."); + } - // FIXME : Pessimistic Lock -// } catch (PessimisticLockException e) { - - // FIXME : Optimistic Lock -// } catch (ObjectOptimisticLockingFailureException e) { -// log.error("좌석 예약 중 락 충돌 발생: screeningId={}, seatNameEnums={}", -// ticketRequestDTO.getScreeningId(), -// seatNameEnums); -// throw new IllegalStateException("동일 좌석이 이미 예약 중입니다. 나중에 다시 시도해 주세요."); -// } - - // FIXME : 명령형 Distibuted Lock -// } catch (InterruptedException e) { -// throw new RuntimeException("예매 처리 중 문제가 발생했습니다."); -// } finally { -// // 락 해제 -// if (lock.isHeldByCurrentThread()) { -// lock.unlock(); -// } -// } + return userRepository.existsById(userId); } /** - * 좌석 연속 여부 확인 (동일한 라인인지) + * 좌석 연속 여부 확인 * */ private boolean areSeatsConsecutive(List seatNameEnums) { if (seatNameEnums == null || seatNameEnums.isEmpty()) { + throw new NullPointerException("좌석 정보가 없습니다."); + } + + Iterator seatIterator = getIntegerIterator(seatNameEnums); + + if (!seatIterator.hasNext()) { return false; } - // 좌석명 코드에서 첫 글자를 추출 - char firstRow = seatNameEnums.get(0).name().charAt(0); - List seatNumbers = seatNameEnums.stream() - .map(seat -> { - String seatName = seat.name(); - // 첫 번째 글자가 동일한지 확인 - if (seatName.charAt(0) != firstRow) { - throw new IllegalArgumentException("좌석은 동일한 행에 있어야 합니다: " + seatName); - } - // 두 번째 글자부터 숫자 추출 - return Integer.parseInt(seatName.substring(1)); - }) - .sorted() - .collect(Collectors.toList()); + int prev = seatIterator.next(); // 첫 번째 좌석 번호 - for (int i = 1; i < seatNumbers.size(); i++) { - if (seatNumbers.get(i) - seatNumbers.get(i - 1) != 1) { - return false; + while (seatIterator.hasNext()) { + int current = seatIterator.next(); + if (prev + 1 != current) { + throw new IllegalArgumentException("좌석은 연속적으로만 선택할 수 있습니다."); } + prev = current; } + return true; } + /** + * 좌석 동일행 확인 + * */ + private static Iterator getIntegerIterator(List seatNameEnums) { + String firstRow = seatNameEnums.get(0).name().substring(0, 1); + + Iterator seatIterator = seatNameEnums.stream() + .map(SeatNameCode::name) + .peek(seatName -> { + if (!seatName.startsWith(firstRow)) { + throw new IllegalArgumentException("좌석은 동일한 행에 있어야 합니다."); + } + }) + .map(seatName -> Integer.parseInt(seatName.substring(1))) + .sorted() + .iterator(); + return seatIterator; + } + /** * 좌석 예매 가능 여부 확인 * */ @@ -170,7 +169,7 @@ private boolean areSeatsBookable(Long screeningId, List seatNameEn for (SeatNameCode seat : seatNameEnums) { Long seatId = seatRepository.findSeatIdByScreeningIdAndSeatNameCd(screeningId, seat.name()) - .orElseThrow(() -> new IllegalArgumentException("좌석 정보를 찾을 수 없습니다: " + seat.name())); + .orElseThrow(() -> new NoSuchElementException("좌석 정보를 찾을 수 없습니다. 상영시간표ID : " + screeningId + ", 좌석명 : " + seat.name())); if (bookedSeats.contains(seatId)) { return false; diff --git a/cinema-common/build.gradle b/cinema-common/build.gradle index a23bf2069..b5a5d0a22 100644 --- a/cinema-common/build.gradle +++ b/cinema-common/build.gradle @@ -9,4 +9,5 @@ version = '0.0.1-SNAPSHOT' dependencies { implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-web' } \ No newline at end of file diff --git a/cinema-common/src/main/java/com/cinema/common/exception/GlobalExceptionHandler.java b/cinema-common/src/main/java/com/cinema/common/exception/GlobalExceptionHandler.java index ef6f0e734..21cb0abd2 100644 --- a/cinema-common/src/main/java/com/cinema/common/exception/GlobalExceptionHandler.java +++ b/cinema-common/src/main/java/com/cinema/common/exception/GlobalExceptionHandler.java @@ -1,24 +1,37 @@ -/* package com.cinema.common.exception; +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.RestControllerAdvice; -import java.util.HashMap; -import java.util.Map; - +import java.util.NoSuchElementException; @RestControllerAdvice public class GlobalExceptionHandler { - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidationExceptions(MethodArgumentNotValidException ex) { - Map errors = new HashMap<>(); - ex.getBindingResult().getFieldErrors().forEach(error -> - errors.put(error.getField(), error.getDefaultMessage()) - ); - return ResponseEntity.badRequest().body(errors); + + @ExceptionHandler(NullPointerException.class) + public ResponseEntity handleNullPointerException(NullPointerException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("필수 데이터가 누락되었습니다: " + ex.getMessage()); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("잘못된 요청: " + ex.getMessage()); + } + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity handleIllegalStateException(IllegalStateException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT).body("요청 처리 중 문제가 발생했습니다: " + ex.getMessage()); + } + + @ExceptionHandler(NoSuchElementException.class) + public ResponseEntity handleNoSuchElementException(NoSuchElementException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("요청한 데이터를 찾을 수 없습니다: " + ex.getMessage()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneralException(Exception ex) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("서버 내부 오류 발생: " + ex.getMessage()); } -} -*/ +} \ No newline at end of file diff --git a/cinema-core/src/main/java/com/cinema/core/domain/Screening.java b/cinema-core/src/main/java/com/cinema/core/domain/Screening.java index 2c3529795..33efa69ad 100644 --- a/cinema-core/src/main/java/com/cinema/core/domain/Screening.java +++ b/cinema-core/src/main/java/com/cinema/core/domain/Screening.java @@ -2,7 +2,6 @@ import jakarta.persistence.*; import lombok.Getter; -import lombok.Setter; import java.io.Serializable; import java.time.LocalDate; @@ -10,7 +9,6 @@ @Entity @Getter -@Setter @Table(name = "screening") public class Screening extends BaseEntity implements Serializable { private static final long serialVersionUID = -1116415509867579126L; diff --git a/cinema-core/src/main/java/com/cinema/core/domain/Seat.java b/cinema-core/src/main/java/com/cinema/core/domain/Seat.java index 0866bd89e..d25b7ba2e 100644 --- a/cinema-core/src/main/java/com/cinema/core/domain/Seat.java +++ b/cinema-core/src/main/java/com/cinema/core/domain/Seat.java @@ -2,13 +2,11 @@ import jakarta.persistence.*; import lombok.Getter; -import lombok.Setter; import java.io.Serializable; @Entity @Getter -@Setter @Table(name = "seat") public class Seat extends BaseEntity implements Serializable { private static final long serialVersionUID = -8004503224579450742L; diff --git a/cinema-core/src/main/java/com/cinema/core/domain/Theater.java b/cinema-core/src/main/java/com/cinema/core/domain/Theater.java index d87fb220f..5bd640894 100644 --- a/cinema-core/src/main/java/com/cinema/core/domain/Theater.java +++ b/cinema-core/src/main/java/com/cinema/core/domain/Theater.java @@ -2,13 +2,11 @@ import jakarta.persistence.*; import lombok.Getter; -import lombok.Setter; import java.io.Serializable; @Entity @Getter -@Setter @Table(name = "theater") public class Theater extends BaseEntity implements Serializable { private static final long serialVersionUID = 8678295794324077135L; diff --git a/cinema-core/src/main/java/com/cinema/core/domain/Ticket.java b/cinema-core/src/main/java/com/cinema/core/domain/Ticket.java index 6a32230a1..c4cd90f4b 100644 --- a/cinema-core/src/main/java/com/cinema/core/domain/Ticket.java +++ b/cinema-core/src/main/java/com/cinema/core/domain/Ticket.java @@ -1,15 +1,16 @@ package com.cinema.core.domain; import jakarta.persistence.*; -import lombok.Getter; -import lombok.Setter; +import lombok.*; import java.io.Serializable; @Entity -@Getter -@Setter @Table(name = "ticket") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor public class Ticket extends BaseEntity implements Serializable { private static final long serialVersionUID = -5368501456583801672L; diff --git a/cinema-core/src/main/java/com/cinema/core/domain/TicketSeat.java b/cinema-core/src/main/java/com/cinema/core/domain/TicketSeat.java index 5ed5f258e..a3b83f2a6 100644 --- a/cinema-core/src/main/java/com/cinema/core/domain/TicketSeat.java +++ b/cinema-core/src/main/java/com/cinema/core/domain/TicketSeat.java @@ -2,13 +2,11 @@ import jakarta.persistence.*; import lombok.Getter; -import lombok.Setter; import java.io.Serializable; @Entity @Getter -@Setter @Table(name = "ticket_seat") public class TicketSeat extends BaseEntity implements Serializable { private static final long serialVersionUID = -9104093970044754227L; diff --git a/cinema-core/src/main/java/com/cinema/core/domain/User.java b/cinema-core/src/main/java/com/cinema/core/domain/User.java index fe5834aa7..2eeae6ee9 100644 --- a/cinema-core/src/main/java/com/cinema/core/domain/User.java +++ b/cinema-core/src/main/java/com/cinema/core/domain/User.java @@ -2,14 +2,12 @@ import jakarta.persistence.*; import lombok.Getter; -import lombok.Setter; import java.io.Serializable; import java.time.LocalDate; @Entity @Getter -@Setter @Table(name = "user") public class User extends BaseEntity implements Serializable { private static final long serialVersionUID = 3213156872610256916L; diff --git a/cinema-infra/src/main/java/com/cinema/infra/dto/MovieScreeningData.java b/cinema-infra/src/main/java/com/cinema/infra/dto/MovieScreeningData.java index 3b346dce9..d525e1781 100644 --- a/cinema-infra/src/main/java/com/cinema/infra/dto/MovieScreeningData.java +++ b/cinema-infra/src/main/java/com/cinema/infra/dto/MovieScreeningData.java @@ -6,7 +6,6 @@ import java.time.LocalTime; @Getter -@Setter @AllArgsConstructor @NoArgsConstructor public class MovieScreeningData { diff --git a/cinema-infra/src/main/java/com/cinema/infra/dto/ScreeningData.java b/cinema-infra/src/main/java/com/cinema/infra/dto/ScreeningData.java index 8aa08f8a5..56db8a695 100644 --- a/cinema-infra/src/main/java/com/cinema/infra/dto/ScreeningData.java +++ b/cinema-infra/src/main/java/com/cinema/infra/dto/ScreeningData.java @@ -3,12 +3,10 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; import java.time.LocalTime; @Getter -@Setter @AllArgsConstructor @NoArgsConstructor public class ScreeningData { diff --git a/cinema-infra/src/main/java/com/cinema/infra/repository/UserRepository.java b/cinema-infra/src/main/java/com/cinema/infra/repository/UserRepository.java new file mode 100644 index 000000000..0369e29a4 --- /dev/null +++ b/cinema-infra/src/main/java/com/cinema/infra/repository/UserRepository.java @@ -0,0 +1,9 @@ +package com.cinema.infra.repository; + +import com.cinema.core.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserRepository extends JpaRepository { +} From 7d298e45ba80939b0188f3164b7ec79c41c5667b Mon Sep 17 00:00:00 2001 From: soyoungcareer Date: Sun, 2 Feb 2025 15:39:07 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fea:=20Guava=20RateLimit=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 --- README.md | 5 + cinema-adapter/build.gradle | 4 + .../adapter/config/RateLimiterConfig.java | 13 ++ .../com/cinema/adapter/config/WebConfig.java | 12 ++ .../adapter/controller/MovieController.java | 10 +- .../adapter/controller/TicketController.java | 5 +- .../interceptor/RateLimitingInterceptor.java | 116 ++++++++++++++++++ .../application/service/MovieService.java | 4 - .../exception/GlobalExceptionHandler.java | 39 ++++-- .../common/response/ApiResponseDTO.java | 27 ++++ compose.yaml | 10 +- ddl.sql => docs/data/ddl.sql | 0 .../data/ddl_no_index.sql | 0 dummy_data.sql => docs/data/dummy_data.sql | 0 .../data/dummy_data_movie.sql | 0 .../data/dummy_data_screening.sql | 0 16 files changed, 221 insertions(+), 24 deletions(-) create mode 100644 cinema-adapter/src/main/java/com/cinema/adapter/config/RateLimiterConfig.java create mode 100644 cinema-adapter/src/main/java/com/cinema/adapter/interceptor/RateLimitingInterceptor.java create mode 100644 cinema-common/src/main/java/com/cinema/common/response/ApiResponseDTO.java rename ddl.sql => docs/data/ddl.sql (100%) rename ddl_no_index.sql => docs/data/ddl_no_index.sql (100%) rename dummy_data.sql => docs/data/dummy_data.sql (100%) rename dummy_data_movie.sql => docs/data/dummy_data_movie.sql (100%) rename dummy_data_screening.sql => docs/data/dummy_data_screening.sql (100%) diff --git a/README.md b/README.md index c8417a946..b7c820291 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,11 @@ >- MySQL >- Docker Compose >- IntelliJ Http Request +>- K6 +>- Junit5 +>- Jacoco +>- Google guava ratelimiter (단일 서버) +>- Redisson (분산 애플리케이션) **Clean Code 작성 요구 사항** >- 가독성 (클래스, 변수, 메서드 이름) diff --git a/cinema-adapter/build.gradle b/cinema-adapter/build.gradle index d21355569..796215273 100644 --- a/cinema-adapter/build.gradle +++ b/cinema-adapter/build.gradle @@ -10,6 +10,7 @@ version = '0.0.1-SNAPSHOT' dependencies { implementation project(':cinema-application') implementation project(':cinema-core') + implementation project(':cinema-common') implementation project(':cinema-infra') implementation 'org.springframework.boot:spring-boot-starter' implementation 'org.springframework.boot:spring-boot-starter-web' @@ -21,6 +22,9 @@ dependencies { // Spring Retry implementation 'org.springframework.retry:spring-retry' implementation 'org.springframework.boot:spring-boot-starter-aop' // AOP 활성화 필요 + + // Google Guava RateLimiter + implementation 'com.google.guava:guava:32.0.0-jre' } tasks.named('bootJar') { diff --git a/cinema-adapter/src/main/java/com/cinema/adapter/config/RateLimiterConfig.java b/cinema-adapter/src/main/java/com/cinema/adapter/config/RateLimiterConfig.java new file mode 100644 index 000000000..e32a13f81 --- /dev/null +++ b/cinema-adapter/src/main/java/com/cinema/adapter/config/RateLimiterConfig.java @@ -0,0 +1,13 @@ +package com.cinema.adapter.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(50.0 / 60.0); // 1분당 50회 + } +} diff --git a/cinema-adapter/src/main/java/com/cinema/adapter/config/WebConfig.java b/cinema-adapter/src/main/java/com/cinema/adapter/config/WebConfig.java index 82b8a4c13..43deeea71 100644 --- a/cinema-adapter/src/main/java/com/cinema/adapter/config/WebConfig.java +++ b/cinema-adapter/src/main/java/com/cinema/adapter/config/WebConfig.java @@ -1,16 +1,22 @@ package com.cinema.adapter.config; +import com.cinema.adapter.interceptor.RateLimitingInterceptor; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.nio.charset.StandardCharsets; import java.util.List; @Configuration +@RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { + private final RateLimitingInterceptor rateLimitingInterceptor; + @Override public void configureMessageConverters(List> converters) { for (HttpMessageConverter converter : converters) { @@ -19,4 +25,10 @@ public void configureMessageConverters(List> converters) } } } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(rateLimitingInterceptor) + .addPathPatterns("/api/v1/movies/**", "/api/v1/tickets/**"); + } } \ No newline at end of file diff --git a/cinema-adapter/src/main/java/com/cinema/adapter/controller/MovieController.java b/cinema-adapter/src/main/java/com/cinema/adapter/controller/MovieController.java index d25bb7131..074b7b278 100644 --- a/cinema-adapter/src/main/java/com/cinema/adapter/controller/MovieController.java +++ b/cinema-adapter/src/main/java/com/cinema/adapter/controller/MovieController.java @@ -3,8 +3,10 @@ import com.cinema.application.dto.MovieRequestDTO; import com.cinema.application.dto.MovieResponseDTO; import com.cinema.application.service.MovieService; +import com.cinema.common.response.ApiResponseDTO; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -20,16 +22,18 @@ public class MovieController { * */ // TODO : 상영일자가 추가되는 경우, bookable 쿼리파라미터를 추가하여 상영가능한 상태의 영화만 조회할 수 있도록 확장 가능 @GetMapping - public List getMovieScreenings(@Valid @RequestBody MovieRequestDTO movieRequestDTO) { - return movieService.getMovieScreenings(movieRequestDTO); + public ResponseEntity>> getMovieScreenings(@Valid @RequestBody MovieRequestDTO movieRequestDTO) { + List movieList = movieService.getMovieScreenings(movieRequestDTO); + return ResponseEntity.ok(ApiResponseDTO.success(movieList, "영화 목록 조회 성공")); } /** * 영화별 상영시간표 캐시 삭제 */ @GetMapping("/evictRedisCache") - public void evictRedisCache() { + public ResponseEntity> evictRedisCache() { movieService.evictShowingMovieCache(); + return ResponseEntity.ok(ApiResponseDTO.success(null, "캐시 삭제 완료")); } } diff --git a/cinema-adapter/src/main/java/com/cinema/adapter/controller/TicketController.java b/cinema-adapter/src/main/java/com/cinema/adapter/controller/TicketController.java index f522f7001..40ad32c7a 100644 --- a/cinema-adapter/src/main/java/com/cinema/adapter/controller/TicketController.java +++ b/cinema-adapter/src/main/java/com/cinema/adapter/controller/TicketController.java @@ -2,6 +2,7 @@ import com.cinema.application.dto.TicketRequestDTO; import com.cinema.application.service.TicketService; +import com.cinema.common.response.ApiResponseDTO; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -17,9 +18,9 @@ public class TicketController { * 예매 * */ @PostMapping - public ResponseEntity bookTickets(@Valid @RequestBody TicketRequestDTO ticketRequestDTO ) { + public ResponseEntity> bookTickets(@Valid @RequestBody TicketRequestDTO ticketRequestDTO) { ticketService.bookTickets(ticketRequestDTO); - return ResponseEntity.ok("예매가 성공적으로 완료되었습니다."); + return ResponseEntity.ok(ApiResponseDTO.success(null, "예매 성공")); } } diff --git a/cinema-adapter/src/main/java/com/cinema/adapter/interceptor/RateLimitingInterceptor.java b/cinema-adapter/src/main/java/com/cinema/adapter/interceptor/RateLimitingInterceptor.java new file mode 100644 index 000000000..9aea5b546 --- /dev/null +++ b/cinema-adapter/src/main/java/com/cinema/adapter/interceptor/RateLimitingInterceptor.java @@ -0,0 +1,116 @@ +package com.cinema.adapter.interceptor; + +import com.cinema.application.dto.TicketRequestDTO; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.util.concurrent.RateLimiter; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.io.BufferedReader; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +@RequiredArgsConstructor +public class RateLimitingInterceptor implements HandlerInterceptor { + + private final RateLimiter rateLimiter; + private final Map requestCounts = new ConcurrentHashMap<>(); + private final Map blockedIps = new ConcurrentHashMap<>(); + private final Map reservationRateLimiters = new ConcurrentHashMap<>(); + private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 파싱을 위한 ObjectMapper + + private static final int MAX_REQUESTS = 50; // 1분 내 최대 50회 + private static final long BLOCK_TIME_MS = 60 * 60 * 1000; // 1시간 차단 + private static final double RESERVATION_RATE = 1.0 / 300; // 5분에 1회 제한 + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String clientIp = request.getRemoteAddr(); + long currentTime = System.currentTimeMillis(); + + // 1. 조회 API 제한 - 차단된 IP인지 확인 + if (blockedIps.containsKey(clientIp)) { + long blockedTime = blockedIps.get(clientIp); + if (currentTime - blockedTime < BLOCK_TIME_MS) { + String unblockTime = Instant.ofEpochMilli(blockedTime + BLOCK_TIME_MS) + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.getWriter().write("해당 IP는 1시간 동안 요청이 차단되었습니다. 차단 해제 시각: " + unblockTime); + return false; + } else { + blockedIps.remove(clientIp); + requestCounts.remove(clientIp); + } + } + + // 2. 조회 API 제한 - 1분 내 50회 + if (request.getRequestURI().startsWith("/api/v1/movies") && request.getMethod().equalsIgnoreCase("GET")) { + requestCounts.put(clientIp, requestCounts.getOrDefault(clientIp, 0) + 1); + if (requestCounts.get(clientIp) > MAX_REQUESTS) { + blockedIps.put(clientIp, currentTime); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.getWriter().write("너무 많은 요청으로 해당 IP는 요청이 차단되었습니다."); + return false; + } + + // 실시간 요청 속도 제한 적용 (RateLimiter) + if (!rateLimiter.tryAcquire()) { + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.getWriter().write("현재 요청량이 너무 많습니다. 잠시 후 다시 시도해주세요."); + return false; + } + } + + // 3. 예매 API 요청 제한 (같은 시간대의 영화 5분에 1번) - TODO : 예매 성공한 경우에 대해서만 체크해야함. + if (request.getRequestURI().startsWith("/api/v1/tickets") && request.getMethod().equalsIgnoreCase("POST")) { + TicketRequestDTO ticketRequestDTO = this.extractTicketRequestDTO(request); + if (ticketRequestDTO == null) { + response.setStatus(HttpStatus.BAD_REQUEST.value()); + response.getWriter().write("예매 요청 데이터가 잘못되었습니다."); + return false; + } + + Long userId = ticketRequestDTO.getUserId(); + Long screeningId = ticketRequestDTO.getScreeningId(); + + if (userId == null || screeningId == null) { + response.setStatus(HttpStatus.BAD_REQUEST.value()); + response.getWriter().write("예매 요청에 필요한 정보가 부족합니다."); + return false; + } + + String reservationKey = userId + "-" + screeningId; + reservationRateLimiters.computeIfAbsent(reservationKey, key -> RateLimiter.create(RESERVATION_RATE)); + + if (!reservationRateLimiters.get(reservationKey).tryAcquire()) { + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.getWriter().write("해당 시간대에 대해 5분 내에 다시 예매할 수 없습니다."); + return false; + } + } + + return true; + } + + /** + * request 에서 TicketRequestDTO 추출 + * */ + private TicketRequestDTO extractTicketRequestDTO(HttpServletRequest request) { + try { + BufferedReader reader = request.getReader(); + return objectMapper.readValue(reader, TicketRequestDTO.class); + } catch (Exception e) { + return null; // JSON 파싱 실패 시 null 반환 + } + } +} diff --git a/cinema-application/src/main/java/com/cinema/application/service/MovieService.java b/cinema-application/src/main/java/com/cinema/application/service/MovieService.java index 5983f352b..9d7f9969e 100644 --- a/cinema-application/src/main/java/com/cinema/application/service/MovieService.java +++ b/cinema-application/src/main/java/com/cinema/application/service/MovieService.java @@ -2,14 +2,11 @@ import com.cinema.application.dto.MovieRequestDTO; import com.cinema.application.dto.MovieResponseDTO; -import com.cinema.application.dto.TicketRequestDTO; import com.cinema.common.enums.GenreCode; import com.cinema.common.enums.GradeCode; -import com.cinema.core.domain.Ticket; import com.cinema.infra.dto.MovieScreeningData; import com.cinema.infra.dto.ScreeningData; import com.cinema.infra.repository.MovieRepository; -import com.cinema.infra.repository.SeatRepository; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; @@ -23,7 +20,6 @@ @Service public class MovieService { private final MovieRepository movieRepository; - private final SeatRepository seatRepository; /** * 영화별 상영시간표 조회 diff --git a/cinema-common/src/main/java/com/cinema/common/exception/GlobalExceptionHandler.java b/cinema-common/src/main/java/com/cinema/common/exception/GlobalExceptionHandler.java index 21cb0abd2..2e4cabe37 100644 --- a/cinema-common/src/main/java/com/cinema/common/exception/GlobalExceptionHandler.java +++ b/cinema-common/src/main/java/com/cinema/common/exception/GlobalExceptionHandler.java @@ -1,9 +1,11 @@ package com.cinema.common.exception; +import com.cinema.common.response.ApiResponseDTO; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.client.HttpClientErrorException; import java.util.NoSuchElementException; @@ -11,27 +13,44 @@ public class GlobalExceptionHandler { @ExceptionHandler(NullPointerException.class) - public ResponseEntity handleNullPointerException(NullPointerException ex) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("필수 데이터가 누락되었습니다: " + ex.getMessage()); + public ResponseEntity> handleNullPointerException(NullPointerException ex) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponseDTO.error(HttpStatus.BAD_REQUEST, 40001, "필수 데이터가 누락되었습니다. " + ex.getMessage())); } @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity handleIllegalArgumentException(IllegalArgumentException ex) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("잘못된 요청: " + ex.getMessage()); + public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException ex) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponseDTO.error(HttpStatus.BAD_REQUEST, 40002, "잘못된 요청: " + ex.getMessage())); } @ExceptionHandler(IllegalStateException.class) - public ResponseEntity handleIllegalStateException(IllegalStateException ex) { - return ResponseEntity.status(HttpStatus.CONFLICT).body("요청 처리 중 문제가 발생했습니다: " + ex.getMessage()); + public ResponseEntity> handleIllegalStateException(IllegalStateException ex) { + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body(ApiResponseDTO.error(HttpStatus.CONFLICT, 409, "요청 처리 중 문제가 발생했습니다: " + ex.getMessage())); } @ExceptionHandler(NoSuchElementException.class) - public ResponseEntity handleNoSuchElementException(NoSuchElementException ex) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body("요청한 데이터를 찾을 수 없습니다: " + ex.getMessage()); + public ResponseEntity> handleNoSuchElementException(NoSuchElementException ex) { + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(ApiResponseDTO.error(HttpStatus.NOT_FOUND, 404, "요청한 데이터를 찾을 수 없습니다. " + ex.getMessage())); + } + + @ExceptionHandler(HttpClientErrorException.TooManyRequests.class) + public ResponseEntity> handleTooManyRequestsException(HttpClientErrorException.TooManyRequests ex) { + return ResponseEntity + .status(HttpStatus.TOO_MANY_REQUESTS) + .body(ApiResponseDTO.error(HttpStatus.TOO_MANY_REQUESTS, 429, "요청량을 초과했습니다.")); } @ExceptionHandler(Exception.class) - public ResponseEntity handleGeneralException(Exception ex) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("서버 내부 오류 발생: " + ex.getMessage()); + public ResponseEntity> handleGeneralException(Exception ex) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponseDTO.error(HttpStatus.INTERNAL_SERVER_ERROR, 500, "서버 내부 오류 발생: " + ex.getMessage())); } } \ No newline at end of file diff --git a/cinema-common/src/main/java/com/cinema/common/response/ApiResponseDTO.java b/cinema-common/src/main/java/com/cinema/common/response/ApiResponseDTO.java new file mode 100644 index 000000000..2c0944a0e --- /dev/null +++ b/cinema-common/src/main/java/com/cinema/common/response/ApiResponseDTO.java @@ -0,0 +1,27 @@ +package com.cinema.common.response; + +import org.springframework.http.HttpStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 공통 API Response + * */ + +@Getter +@AllArgsConstructor +public class ApiResponseDTO { + private HttpStatus status; + private int customCode; + private String message; + private T data; + + public static ApiResponseDTO success(T data, String message) { + return new ApiResponseDTO<>(HttpStatus.OK, 200, message, data); + } + + public static ApiResponseDTO error(HttpStatus status, int customCode, String message) { + return new ApiResponseDTO<>(status, customCode, message, null); + } +} + diff --git a/compose.yaml b/compose.yaml index 876ddc452..c9f89296c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,11 +12,11 @@ services: TZ: Asia/Seoul volumes: - db_data:/var/lib/mysql -# - ./ddl_no_index.sql:/docker-entrypoint-initdb.d/ddl_no_index.sql:ro - - ./ddl.sql:/docker-entrypoint-initdb.d/ddl.sql:ro - - ./dummy_data.sql:/docker-entrypoint-initdb.d/dummy_data.sql:ro - - ./dummy_data_movie.sql:/docker-entrypoint-initdb.d/dummy_data_movie.sql:ro - - ./dummy_data_screening.sql:/docker-entrypoint-initdb.d/dummy_data_screening.sql:ro +# - ./docs/data/ddl_no_index.sql:/docker-entrypoint-initdb.d/ddl_no_index.sql:ro + - ./docs/data/ddl.sql:/docker-entrypoint-initdb.d/ddl.sql:ro + - ./docs/data/dummy_data.sql:/docker-entrypoint-initdb.d/dummy_data.sql:ro + - ./docs/data/dummy_data_movie.sql:/docker-entrypoint-initdb.d/dummy_data_movie.sql:ro + - ./docs/data/dummy_data_screening.sql:/docker-entrypoint-initdb.d/dummy_data_screening.sql:ro command: [ "--character-set-server=utf8mb4", # MySQL 서버 기본 문자 집합을 utf8mb4로 설정 "--collation-server=utf8mb4_general_ci", # MySQL 서버 기본 정렬 규칙을 utf8mb4_general_ci로 설정 diff --git a/ddl.sql b/docs/data/ddl.sql similarity index 100% rename from ddl.sql rename to docs/data/ddl.sql diff --git a/ddl_no_index.sql b/docs/data/ddl_no_index.sql similarity index 100% rename from ddl_no_index.sql rename to docs/data/ddl_no_index.sql diff --git a/dummy_data.sql b/docs/data/dummy_data.sql similarity index 100% rename from dummy_data.sql rename to docs/data/dummy_data.sql diff --git a/dummy_data_movie.sql b/docs/data/dummy_data_movie.sql similarity index 100% rename from dummy_data_movie.sql rename to docs/data/dummy_data_movie.sql diff --git a/dummy_data_screening.sql b/docs/data/dummy_data_screening.sql similarity index 100% rename from dummy_data_screening.sql rename to docs/data/dummy_data_screening.sql From 02571d67774d35d16639a5ccbe6d5673e0803740 Mon Sep 17 00:00:00 2001 From: soyoungcareer Date: Sun, 2 Feb 2025 16:51:13 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=EC=98=88=EB=A7=A4=20=EC=84=B1?= =?UTF-8?q?=EA=B3=B5=ED=95=9C=20=EA=B2=BD=EC=9A=B0=EC=97=90=EB=A7=8C=205?= =?UTF-8?q?=EB=B6=84=20=EC=A0=9C=ED=95=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interceptor/RateLimitingInterceptor.java | 46 ------- cinema-application/build.gradle | 3 + .../application/service/TicketService.java | 118 ++++++++++++------ 3 files changed, 82 insertions(+), 85 deletions(-) diff --git a/cinema-adapter/src/main/java/com/cinema/adapter/interceptor/RateLimitingInterceptor.java b/cinema-adapter/src/main/java/com/cinema/adapter/interceptor/RateLimitingInterceptor.java index 9aea5b546..5979ddd20 100644 --- a/cinema-adapter/src/main/java/com/cinema/adapter/interceptor/RateLimitingInterceptor.java +++ b/cinema-adapter/src/main/java/com/cinema/adapter/interceptor/RateLimitingInterceptor.java @@ -1,7 +1,5 @@ package com.cinema.adapter.interceptor; -import com.cinema.application.dto.TicketRequestDTO; -import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.util.concurrent.RateLimiter; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -10,7 +8,6 @@ import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; -import java.io.BufferedReader; import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; @@ -24,12 +21,9 @@ public class RateLimitingInterceptor implements HandlerInterceptor { private final RateLimiter rateLimiter; private final Map requestCounts = new ConcurrentHashMap<>(); private final Map blockedIps = new ConcurrentHashMap<>(); - private final Map reservationRateLimiters = new ConcurrentHashMap<>(); - private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 파싱을 위한 ObjectMapper private static final int MAX_REQUESTS = 50; // 1분 내 최대 50회 private static final long BLOCK_TIME_MS = 60 * 60 * 1000; // 1시간 차단 - private static final double RESERVATION_RATE = 1.0 / 300; // 5분에 1회 제한 @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { @@ -71,46 +65,6 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons } } - // 3. 예매 API 요청 제한 (같은 시간대의 영화 5분에 1번) - TODO : 예매 성공한 경우에 대해서만 체크해야함. - if (request.getRequestURI().startsWith("/api/v1/tickets") && request.getMethod().equalsIgnoreCase("POST")) { - TicketRequestDTO ticketRequestDTO = this.extractTicketRequestDTO(request); - if (ticketRequestDTO == null) { - response.setStatus(HttpStatus.BAD_REQUEST.value()); - response.getWriter().write("예매 요청 데이터가 잘못되었습니다."); - return false; - } - - Long userId = ticketRequestDTO.getUserId(); - Long screeningId = ticketRequestDTO.getScreeningId(); - - if (userId == null || screeningId == null) { - response.setStatus(HttpStatus.BAD_REQUEST.value()); - response.getWriter().write("예매 요청에 필요한 정보가 부족합니다."); - return false; - } - - String reservationKey = userId + "-" + screeningId; - reservationRateLimiters.computeIfAbsent(reservationKey, key -> RateLimiter.create(RESERVATION_RATE)); - - if (!reservationRateLimiters.get(reservationKey).tryAcquire()) { - response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); - response.getWriter().write("해당 시간대에 대해 5분 내에 다시 예매할 수 없습니다."); - return false; - } - } - return true; } - - /** - * request 에서 TicketRequestDTO 추출 - * */ - private TicketRequestDTO extractTicketRequestDTO(HttpServletRequest request) { - try { - BufferedReader reader = request.getReader(); - return objectMapper.readValue(reader, TicketRequestDTO.class); - } catch (Exception e) { - return null; // JSON 파싱 실패 시 null 반환 - } - } } diff --git a/cinema-application/build.gradle b/cinema-application/build.gradle index eb5ec611d..0d6c69126 100644 --- a/cinema-application/build.gradle +++ b/cinema-application/build.gradle @@ -29,4 +29,7 @@ dependencies { // Redisson implementation 'org.redisson:redisson-spring-boot-starter:3.21.0' + + // Google Guava RateLimiter + implementation 'com.google.guava:guava:32.0.0-jre' } \ No newline at end of file diff --git a/cinema-application/src/main/java/com/cinema/application/service/TicketService.java b/cinema-application/src/main/java/com/cinema/application/service/TicketService.java index 0a3fa43dc..4664c151a 100644 --- a/cinema-application/src/main/java/com/cinema/application/service/TicketService.java +++ b/cinema-application/src/main/java/com/cinema/application/service/TicketService.java @@ -2,6 +2,7 @@ import com.cinema.application.dto.TicketRequestDTO; import com.cinema.common.enums.SeatNameCode; +import com.cinema.common.response.ApiResponseDTO; import com.cinema.core.domain.Ticket; import com.cinema.core.domain.TicketSeat; import com.cinema.infra.lock.DistributedLockUtil; @@ -9,6 +10,7 @@ import com.cinema.infra.repository.TicketRepository; import com.cinema.infra.repository.TicketSeatRepository; import com.cinema.infra.repository.UserRepository; +import com.google.common.util.concurrent.RateLimiter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.redisson.api.RedissonClient; @@ -21,7 +23,9 @@ import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.NoSuchElementException; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; @Service @@ -36,6 +40,9 @@ public class TicketService { private final RedissonClient redissonClient; private final DistributedLockUtil lockUtil; + private final Map reservationRateLimiters = new ConcurrentHashMap<>(); + private static final double RESERVATION_RATE = 1.0 / 300; // 5분에 1회 제한 + @Value("${max-count.theater-bookable}") private int maxTheaterBookableCnt; @@ -44,10 +51,82 @@ public class TicketService { * */ @Retryable(value = OptimisticLockingFailureException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000)) public void bookTickets(TicketRequestDTO ticketRequestDTO) { + Long userId = ticketRequestDTO.getUserId(); + Long screeningId = ticketRequestDTO.getScreeningId(); + + if (userId == null || screeningId == null) { + throw new NoSuchElementException("유효하지 않은 요청 데이터입니다."); + } + + // RateLimit 적용 + String reservationKey = userId + "-" + screeningId; + + // 예매 가능 여부 확인 + if (reservationRateLimiters.containsKey(reservationKey) && + !reservationRateLimiters.get(reservationKey).tryAcquire()) { + throw new IllegalStateException("해당 상영 일정에 대해 5분 내에 다시 예매할 수 없습니다."); + } + List seatNameEnums = ticketRequestDTO.getSeatNames().stream() .map(SeatNameCode::fromString) .collect(Collectors.toList()); + this.ticketsValidCheck(ticketRequestDTO, seatNameEnums); + + // lock을 획득한 요청에 대해서만 Transactional 적용하기 위해 분리 + String lockKey = "lock:screening:" + ticketRequestDTO.getScreeningId(); + boolean bookingSuccess = lockUtil.executeWithLock(lockKey, 5, 3, () -> + bookTicketsWithTransaction(ticketRequestDTO, seatNameEnums) + ); + + // 예매 성공 시에만 5분 제한 적용 + if (bookingSuccess) { + reservationRateLimiters.computeIfAbsent(reservationKey, key -> RateLimiter.create(RESERVATION_RATE)); + reservationRateLimiters.get(reservationKey).tryAcquire(); + log.info("예매 성공 - {} 제한 적용 (5분)", reservationKey); + } else { + log.error("예매 실패 - {} 제한 적용되지 않음", reservationKey); + } + } + + /** + * 예매 정보 저장 + * */ + @Transactional + public boolean bookTicketsWithTransaction(TicketRequestDTO ticketRequestDTO, List seatNameEnums) { + try { + // 예매 저장 + Ticket ticket = Ticket.builder() + .userId(ticketRequestDTO.getUserId()) + .screeningId(ticketRequestDTO.getScreeningId()) + .build(); + Ticket savedTicket = ticketRepository.save(ticket); + ticketRepository.flush(); + + // 예매 좌석 저장 + List ticketSeats = seatNameEnums.stream() + .map(seat -> { + Long seatId = seatRepository.findSeatIdByScreeningIdAndSeatNameCd( + ticketRequestDTO.getScreeningId(), seat.name() + ).orElseThrow(() -> new NoSuchElementException("좌석 정보를 찾을 수 없습니다. 상영시간표ID : " + ticketRequestDTO.getScreeningId() + ", 좌석명 : " + seat.name())); + + return new TicketSeat(savedTicket.getTicketId(), seatId); + }) + .collect(Collectors.toList()); + + ticketSeatRepository.saveAll(ticketSeats); + ticketSeatRepository.flush(); + return true; + } catch (Exception e) { + log.error("예매 정보 저장 실패: {}", e.getMessage()); + return false; + } + } + + /** + * 유효성 검증 + * */ + private void ticketsValidCheck(TicketRequestDTO ticketRequestDTO, List seatNameEnums) { // 사용자 확인 if (!this.isUserExists(ticketRequestDTO.getUserId())) { throw new NoSuchElementException("사용자 정보가 없습니다. ID : " + ticketRequestDTO.getUserId()); @@ -67,51 +146,12 @@ public void bookTickets(TicketRequestDTO ticketRequestDTO) { if (!this.isBookingExceed(ticketRequestDTO, seatNameEnums.size())) { throw new IllegalStateException("상영시간표당 예매 가능 좌석 수를 초과하였습니다."); } - - // lock을 획득한 요청에 대해서만 Transactional 적용하기 위해 분리 - String lockKey = "lock:screening:" + ticketRequestDTO.getScreeningId(); - lockUtil.executeWithLock(lockKey, 5, 3, () -> { - bookTicketsWithTransaction(ticketRequestDTO, seatNameEnums); - return null; - }); - } - - /** - * 예매 정보 저장 - * */ - @Transactional - public void bookTicketsWithTransaction(TicketRequestDTO ticketRequestDTO, List seatNameEnums) { - // 예매 저장 - Ticket ticket = Ticket.builder() - .userId(ticketRequestDTO.getUserId()) - .screeningId(ticketRequestDTO.getScreeningId()) - .build(); - Ticket savedTicket = ticketRepository.save(ticket); - ticketRepository.flush(); - - // 예매 좌석 저장 - List ticketSeats = seatNameEnums.stream() - .map(seat -> { - Long seatId = seatRepository.findSeatIdByScreeningIdAndSeatNameCd( - ticketRequestDTO.getScreeningId(), seat.name() - ).orElseThrow(() -> new NoSuchElementException("좌석 정보를 찾을 수 없습니다. 상영시간표ID : " + ticketRequestDTO.getScreeningId() + ", 좌석명 : " + seat.name())); - - return new TicketSeat(savedTicket.getTicketId(), seatId); - }) - .collect(Collectors.toList()); - - ticketSeatRepository.saveAll(ticketSeats); - ticketSeatRepository.flush(); } /** * 사용자 확인 * */ private boolean isUserExists(Long userId) { - if (userId == null) { - throw new NullPointerException("사용자 정보가 없습니다."); - } - return userRepository.existsById(userId); } From 6b9bc94a6f0780a910c4b66952c78d06cecb93f5 Mon Sep 17 00:00:00 2001 From: soyoungcareer Date: Sun, 2 Feb 2025 18:56:17 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++ .../java/com/cinema/PessimisticLockTest.java | 23 ------- .../controller/MovieControllerTest.java | 66 +++++++++++++++++++ .../controller/TicketControllerTest.java | 59 +++++++++++++++++ .../src/test/resources/application-test.yml | 43 ++++++++++++ .../application/dto/TicketRequestDTO.java | 2 + .../application/service/TicketService.java | 3 +- .../exception/GlobalExceptionHandler.java | 4 +- .../exception/TooManyRequestsException.java | 13 ++++ 9 files changed, 191 insertions(+), 26 deletions(-) delete mode 100644 cinema-adapter/src/test/java/com/cinema/PessimisticLockTest.java create mode 100644 cinema-adapter/src/test/java/com/cinema/adapter/controller/MovieControllerTest.java create mode 100644 cinema-adapter/src/test/java/com/cinema/adapter/controller/TicketControllerTest.java create mode 100644 cinema-adapter/src/test/resources/application-test.yml create mode 100644 cinema-common/src/main/java/com/cinema/common/exception/TooManyRequestsException.java diff --git a/README.md b/README.md index b7c820291..1246fc48d 100644 --- a/README.md +++ b/README.md @@ -588,6 +588,10 @@ Querydsl로 변경하여 한 번에 조회할 수 있도록 변경함. --- # [4주차] 서버 안정성을 높이기 위한 RateLimit 구현 +## Google Guava Ratelimiter +- 조회API 테스트 시 429 에러가 51번째부터 발생해야 하는데, 두번째 요청부터 훨씬 빠르게 발생함. +- 예매API 테스트 429 반환 성공. + diff --git a/cinema-adapter/src/test/java/com/cinema/PessimisticLockTest.java b/cinema-adapter/src/test/java/com/cinema/PessimisticLockTest.java deleted file mode 100644 index e1af1a4ca..000000000 --- a/cinema-adapter/src/test/java/com/cinema/PessimisticLockTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.cinema; - -import com.cinema.application.service.TicketService; -import com.cinema.infra.repository.TicketRepository; -import jakarta.transaction.Transactional; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -@Transactional -public class PessimisticLockTest { - - @Autowired - private TicketRepository ticketRepository; - - @Autowired - private TicketService ticketService; - - @Test - void testPessimisticLock() throws InterruptedException { - } -} diff --git a/cinema-adapter/src/test/java/com/cinema/adapter/controller/MovieControllerTest.java b/cinema-adapter/src/test/java/com/cinema/adapter/controller/MovieControllerTest.java new file mode 100644 index 000000000..1d96c2f60 --- /dev/null +++ b/cinema-adapter/src/test/java/com/cinema/adapter/controller/MovieControllerTest.java @@ -0,0 +1,66 @@ +package com.cinema.adapter.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class MovieControllerTest { + + @LocalServerPort + private int port; + + private String baseUrl; + private RestTemplate restTemplate; + + @BeforeEach + void setUp() { + baseUrl = "http://localhost:" + port + "/api/v1/movies"; + restTemplate = new RestTemplate(); + } + + @Test + void testRateLimitForMovies() throws InterruptedException { + int requestCount = 51; + int delayBetweenRequestsMs = 100; // 각 요청 사이의 대기 시간 (100ms) + + ExecutorService executorService = Executors.newFixedThreadPool(10); + int[] rateLimitExceededCount = {0}; // 429 응답 개수 카운트 + + for (int i = 0; i < requestCount; i++) { + executorService.submit(() -> { + try { + ResponseEntity response = restTemplate.getForEntity(baseUrl, String.class); + System.out.println("Response: " + response.getStatusCode()); + } catch (HttpClientErrorException.TooManyRequests e) { + System.out.println("429 Too Many Requests received."); + synchronized (rateLimitExceededCount) { + rateLimitExceededCount[0]++; + } + } + + try { + Thread.sleep(delayBetweenRequestsMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } + + executorService.shutdown(); + executorService.awaitTermination(1, TimeUnit.MINUTES); + + // 429 응답이 하나라도 나와야 테스트 통과 + assertThat(rateLimitExceededCount[0]).isGreaterThanOrEqualTo(1); + } +} diff --git a/cinema-adapter/src/test/java/com/cinema/adapter/controller/TicketControllerTest.java b/cinema-adapter/src/test/java/com/cinema/adapter/controller/TicketControllerTest.java new file mode 100644 index 000000000..6f8b6925e --- /dev/null +++ b/cinema-adapter/src/test/java/com/cinema/adapter/controller/TicketControllerTest.java @@ -0,0 +1,59 @@ +package com.cinema.adapter.controller; + +import com.cinema.application.dto.TicketRequestDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class TicketControllerTest { + + @LocalServerPort + private int port; + + private String baseUrl; + private RestTemplate restTemplate; + + @BeforeEach + void setUp() { + baseUrl = "http://localhost:" + port + "/api/v1/tickets"; + restTemplate = new RestTemplate(); + } + + @Test + void testRateLimitForTickets() { + Long userId = 1L; + Long screeningId = 3L; + + HttpHeaders headers = new HttpHeaders(); + + // 첫 번째 요청: 정상 예매 + TicketRequestDTO requestDTO = new TicketRequestDTO(screeningId, userId, List.of("A1")); + HttpEntity request = new HttpEntity<>(requestDTO, headers); + ResponseEntity firstResponse = restTemplate.exchange(baseUrl, HttpMethod.POST, request, String.class); + assertThat(firstResponse.getStatusCodeValue()).isEqualTo(200); + + // 두 번째 요청: 5분 제한 적용되어야 함 (429 반환 예상) + TicketRequestDTO requestDTO2 = new TicketRequestDTO(screeningId, userId, List.of("A2")); + HttpEntity request2 = new HttpEntity<>(requestDTO2, headers); + + try { + restTemplate.exchange(baseUrl, HttpMethod.POST, request2, String.class); + System.out.println("test failed!!!"); + } catch (HttpClientErrorException.TooManyRequests e) { + System.out.println("429 Too Many Requests received as expected."); + assertThat(e.getStatusCode().value()).isEqualTo(429); + } + } +} diff --git a/cinema-adapter/src/test/resources/application-test.yml b/cinema-adapter/src/test/resources/application-test.yml new file mode 100644 index 000000000..6c615c0bc --- /dev/null +++ b/cinema-adapter/src/test/resources/application-test.yml @@ -0,0 +1,43 @@ +spring: + datasource: + url: jdbc:mysql://localhost:3307/cinema?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul + username: cinema_user + password: cinema_password + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: create-drop + naming: + physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl + implicit-strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect + format_sql: true + show-sql: true + data: + redis: + host: localhost + # host: redis-container + port: 6379 + # password: cinema_password + serializer: jackson + cache: + type: redis + http: + encoding: + charset: UTF-8 + enabled: true + force: true + jackson: + serialization: + fail-on-empty-beans: false + +# 상영시간표별 최대 예매 가능 수 +max-count: + theater-bookable: 5 + +logging: + level: + org.springframework.transaction: DEBUG + org.hibernate.SQL: DEBUG diff --git a/cinema-application/src/main/java/com/cinema/application/dto/TicketRequestDTO.java b/cinema-application/src/main/java/com/cinema/application/dto/TicketRequestDTO.java index fddfcbd99..53c3a1862 100644 --- a/cinema-application/src/main/java/com/cinema/application/dto/TicketRequestDTO.java +++ b/cinema-application/src/main/java/com/cinema/application/dto/TicketRequestDTO.java @@ -1,11 +1,13 @@ package com.cinema.application.dto; import jakarta.validation.constraints.*; +import lombok.AllArgsConstructor; import lombok.Getter; import java.util.List; @Getter +@AllArgsConstructor public class TicketRequestDTO { @NotNull private Long screeningId; diff --git a/cinema-application/src/main/java/com/cinema/application/service/TicketService.java b/cinema-application/src/main/java/com/cinema/application/service/TicketService.java index 4664c151a..7d8f71426 100644 --- a/cinema-application/src/main/java/com/cinema/application/service/TicketService.java +++ b/cinema-application/src/main/java/com/cinema/application/service/TicketService.java @@ -2,6 +2,7 @@ import com.cinema.application.dto.TicketRequestDTO; import com.cinema.common.enums.SeatNameCode; +import com.cinema.common.exception.TooManyRequestsException; import com.cinema.common.response.ApiResponseDTO; import com.cinema.core.domain.Ticket; import com.cinema.core.domain.TicketSeat; @@ -64,7 +65,7 @@ public void bookTickets(TicketRequestDTO ticketRequestDTO) { // 예매 가능 여부 확인 if (reservationRateLimiters.containsKey(reservationKey) && !reservationRateLimiters.get(reservationKey).tryAcquire()) { - throw new IllegalStateException("해당 상영 일정에 대해 5분 내에 다시 예매할 수 없습니다."); + throw new TooManyRequestsException("해당 상영 일정에 대해 5분 내에 다시 예매할 수 없습니다."); } List seatNameEnums = ticketRequestDTO.getSeatNames().stream() diff --git a/cinema-common/src/main/java/com/cinema/common/exception/GlobalExceptionHandler.java b/cinema-common/src/main/java/com/cinema/common/exception/GlobalExceptionHandler.java index 2e4cabe37..cb8711ee4 100644 --- a/cinema-common/src/main/java/com/cinema/common/exception/GlobalExceptionHandler.java +++ b/cinema-common/src/main/java/com/cinema/common/exception/GlobalExceptionHandler.java @@ -40,8 +40,8 @@ public ResponseEntity> handleNoSuchElementException(NoSuc .body(ApiResponseDTO.error(HttpStatus.NOT_FOUND, 404, "요청한 데이터를 찾을 수 없습니다. " + ex.getMessage())); } - @ExceptionHandler(HttpClientErrorException.TooManyRequests.class) - public ResponseEntity> handleTooManyRequestsException(HttpClientErrorException.TooManyRequests ex) { + @ExceptionHandler(TooManyRequestsException.class) + public ResponseEntity> handleTooManyRequestsException(TooManyRequestsException ex) { return ResponseEntity .status(HttpStatus.TOO_MANY_REQUESTS) .body(ApiResponseDTO.error(HttpStatus.TOO_MANY_REQUESTS, 429, "요청량을 초과했습니다.")); diff --git a/cinema-common/src/main/java/com/cinema/common/exception/TooManyRequestsException.java b/cinema-common/src/main/java/com/cinema/common/exception/TooManyRequestsException.java new file mode 100644 index 000000000..c16350f03 --- /dev/null +++ b/cinema-common/src/main/java/com/cinema/common/exception/TooManyRequestsException.java @@ -0,0 +1,13 @@ +package com.cinema.common.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class TooManyRequestsException extends RuntimeException { + private final HttpStatus status = HttpStatus.TOO_MANY_REQUESTS; + + public TooManyRequestsException(String message) { + super(message); + } +} From e668361c2cd21ff938adfa321e0b3738c38b97ac Mon Sep 17 00:00:00 2001 From: soyoungcareer Date: Sun, 2 Feb 2025 19:40:18 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20Redisson=20RateLimit=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 --- README.md | 3 ++ cinema-adapter/build.gradle | 3 ++ .../adapter/config/RateLimiterConfig.java | 25 ++++++++++++++- .../interceptor/RateLimitingInterceptor.java | 16 ++++++++-- .../controller/TicketControllerTest.java | 4 +-- .../application/service/TicketService.java | 32 ++++++++++++++++--- 6 files changed, 73 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 1246fc48d..df2c78dab 100644 --- a/README.md +++ b/README.md @@ -592,6 +592,9 @@ Querydsl로 변경하여 한 번에 조회할 수 있도록 변경함. - 조회API 테스트 시 429 에러가 51번째부터 발생해야 하는데, 두번째 요청부터 훨씬 빠르게 발생함. - 예매API 테스트 429 반환 성공. +## Redisson +- 조회API, 예매API 테스트 모두 429 반환 성공. + diff --git a/cinema-adapter/build.gradle b/cinema-adapter/build.gradle index 796215273..c4b3be8f4 100644 --- a/cinema-adapter/build.gradle +++ b/cinema-adapter/build.gradle @@ -25,6 +25,9 @@ dependencies { // Google Guava RateLimiter implementation 'com.google.guava:guava:32.0.0-jre' + + // Redisson + implementation 'org.redisson:redisson-spring-boot-starter:3.21.0' } tasks.named('bootJar') { diff --git a/cinema-adapter/src/main/java/com/cinema/adapter/config/RateLimiterConfig.java b/cinema-adapter/src/main/java/com/cinema/adapter/config/RateLimiterConfig.java index e32a13f81..28a707eca 100644 --- a/cinema-adapter/src/main/java/com/cinema/adapter/config/RateLimiterConfig.java +++ b/cinema-adapter/src/main/java/com/cinema/adapter/config/RateLimiterConfig.java @@ -1,13 +1,36 @@ package com.cinema.adapter.config; import com.google.common.util.concurrent.RateLimiter; +import org.redisson.api.RRateLimiter; +import org.redisson.api.RateIntervalUnit; +import org.redisson.api.RateType; +import org.redisson.api.RedissonClient; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class RateLimiterConfig { - @Bean + + // Google Guava + /*@Bean public RateLimiter rateLimiter() { return RateLimiter.create(50.0 / 60.0); // 1분당 50회 + }*/ + + + // Redisson + private static final String RATE_LIMIT_KEY = "rate_limit:requests"; + + private final RedissonClient redissonClient; + + public RateLimiterConfig(RedissonClient redissonClient) { + this.redissonClient = redissonClient; + } + + @Bean + public RRateLimiter rateLimiter() { + RRateLimiter rateLimiter = redissonClient.getRateLimiter(RATE_LIMIT_KEY); + rateLimiter.trySetRate(RateType.OVERALL, 50, 1, RateIntervalUnit.MINUTES); // 1분당 50회 + return rateLimiter; } } diff --git a/cinema-adapter/src/main/java/com/cinema/adapter/interceptor/RateLimitingInterceptor.java b/cinema-adapter/src/main/java/com/cinema/adapter/interceptor/RateLimitingInterceptor.java index 5979ddd20..82922f959 100644 --- a/cinema-adapter/src/main/java/com/cinema/adapter/interceptor/RateLimitingInterceptor.java +++ b/cinema-adapter/src/main/java/com/cinema/adapter/interceptor/RateLimitingInterceptor.java @@ -4,6 +4,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import org.redisson.api.RRateLimiter; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; @@ -13,12 +14,15 @@ import java.time.format.DateTimeFormatter; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; @Component @RequiredArgsConstructor public class RateLimitingInterceptor implements HandlerInterceptor { - private final RateLimiter rateLimiter; +// private final RateLimiter rateLimiter; // Google Guava + private final RRateLimiter rateLimiter; // Redisson + private final Map requestCounts = new ConcurrentHashMap<>(); private final Map blockedIps = new ConcurrentHashMap<>(); @@ -58,7 +62,15 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons } // 실시간 요청 속도 제한 적용 (RateLimiter) - if (!rateLimiter.tryAcquire()) { + // Google Guava + /*if (!rateLimiter.tryAcquire()) { + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.getWriter().write("현재 요청량이 너무 많습니다. 잠시 후 다시 시도해주세요."); + return false; + }*/ + + // Redisson + if (!rateLimiter.tryAcquire(1, TimeUnit.SECONDS)) { response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); response.getWriter().write("현재 요청량이 너무 많습니다. 잠시 후 다시 시도해주세요."); return false; diff --git a/cinema-adapter/src/test/java/com/cinema/adapter/controller/TicketControllerTest.java b/cinema-adapter/src/test/java/com/cinema/adapter/controller/TicketControllerTest.java index 6f8b6925e..27c9dd14b 100644 --- a/cinema-adapter/src/test/java/com/cinema/adapter/controller/TicketControllerTest.java +++ b/cinema-adapter/src/test/java/com/cinema/adapter/controller/TicketControllerTest.java @@ -39,13 +39,13 @@ void testRateLimitForTickets() { HttpHeaders headers = new HttpHeaders(); // 첫 번째 요청: 정상 예매 - TicketRequestDTO requestDTO = new TicketRequestDTO(screeningId, userId, List.of("A1")); + TicketRequestDTO requestDTO = new TicketRequestDTO(screeningId, userId, List.of("E1")); HttpEntity request = new HttpEntity<>(requestDTO, headers); ResponseEntity firstResponse = restTemplate.exchange(baseUrl, HttpMethod.POST, request, String.class); assertThat(firstResponse.getStatusCodeValue()).isEqualTo(200); // 두 번째 요청: 5분 제한 적용되어야 함 (429 반환 예상) - TicketRequestDTO requestDTO2 = new TicketRequestDTO(screeningId, userId, List.of("A2")); + TicketRequestDTO requestDTO2 = new TicketRequestDTO(screeningId, userId, List.of("E2")); HttpEntity request2 = new HttpEntity<>(requestDTO2, headers); try { diff --git a/cinema-application/src/main/java/com/cinema/application/service/TicketService.java b/cinema-application/src/main/java/com/cinema/application/service/TicketService.java index 7d8f71426..f20cbd0cf 100644 --- a/cinema-application/src/main/java/com/cinema/application/service/TicketService.java +++ b/cinema-application/src/main/java/com/cinema/application/service/TicketService.java @@ -14,6 +14,9 @@ import com.google.common.util.concurrent.RateLimiter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RRateLimiter; +import org.redisson.api.RateIntervalUnit; +import org.redisson.api.RateType; import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.OptimisticLockingFailureException; @@ -38,12 +41,16 @@ public class TicketService { private final SeatRepository seatRepository; private final UserRepository userRepository; - private final RedissonClient redissonClient; private final DistributedLockUtil lockUtil; + // Google Guava private final Map reservationRateLimiters = new ConcurrentHashMap<>(); private static final double RESERVATION_RATE = 1.0 / 300; // 5분에 1회 제한 + // Redisson + private final RedissonClient redissonClient; + private static final String RESERVATION_LIMIT_PREFIX = "rate_limit:reservation:"; + @Value("${max-count.theater-bookable}") private int maxTheaterBookableCnt; @@ -60,12 +67,22 @@ public void bookTickets(TicketRequestDTO ticketRequestDTO) { } // RateLimit 적용 + // 예매 가능 여부 확인 + // 1) Google Guava + /* String reservationKey = userId + "-" + screeningId; - // 예매 가능 여부 확인 if (reservationRateLimiters.containsKey(reservationKey) && !reservationRateLimiters.get(reservationKey).tryAcquire()) { throw new TooManyRequestsException("해당 상영 일정에 대해 5분 내에 다시 예매할 수 없습니다."); + }*/ + + // 2) Redisson + String reservationKey = RESERVATION_LIMIT_PREFIX + userId + ":" + screeningId; + RRateLimiter rateLimiter = redissonClient.getRateLimiter(reservationKey); + + if (rateLimiter.isExists() && !rateLimiter.tryAcquire()) { + throw new TooManyRequestsException("해당 상영 일정에 대해 5분 내에 다시 예매할 수 없습니다."); } List seatNameEnums = ticketRequestDTO.getSeatNames().stream() @@ -82,11 +99,16 @@ public void bookTickets(TicketRequestDTO ticketRequestDTO) { // 예매 성공 시에만 5분 제한 적용 if (bookingSuccess) { - reservationRateLimiters.computeIfAbsent(reservationKey, key -> RateLimiter.create(RESERVATION_RATE)); - reservationRateLimiters.get(reservationKey).tryAcquire(); + // 1) Google Guava + /*reservationRateLimiters.computeIfAbsent(reservationKey, key -> RateLimiter.create(RESERVATION_RATE)); + reservationRateLimiters.get(reservationKey).tryAcquire();*/ + + // 2) Redisson + rateLimiter.trySetRate(RateType.PER_CLIENT, 1, 5, RateIntervalUnit.MINUTES); // 5분에 1회 제한 + rateLimiter.tryAcquire(); log.info("예매 성공 - {} 제한 적용 (5분)", reservationKey); } else { - log.error("예매 실패 - {} 제한 적용되지 않음", reservationKey); + log.error("예매 실패 - {} 제한 적용되지 않음"); } } From ec2db4e5740dc1a12dfb33a9e142d33fa834a5e7 Mon Sep 17 00:00:00 2001 From: soyoungcareer Date: Sun, 2 Feb 2025 20:05:54 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20Jacoco=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cinema-adapter/build.gradle | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cinema-adapter/build.gradle b/cinema-adapter/build.gradle index c4b3be8f4..f6fa1c0c1 100644 --- a/cinema-adapter/build.gradle +++ b/cinema-adapter/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' id 'io.spring.dependency-management' // 루트의 dependency-management 상속 + id 'jacoco' } group = 'com.cinema' @@ -32,4 +33,26 @@ dependencies { tasks.named('bootJar') { mainClass = 'com.cinema.CinemaApplication' +} + +tasks.jacocoTestReport { + dependsOn test // test 실행 후 보고서 생성 + reports { + xml.required = true + csv.required = false + html.destination file("${buildDir}/jacocoHtml") + } +} + +tasks.jacocoTestCoverageVerification { + violationRules { + rule { + element = 'CLASS' + limit { + counter = 'LINE' + value = 'COVEREDRATIO' + minimum = 0.60 // 최소 60% 커버리지 필요 + } + } + } } \ No newline at end of file