diff --git a/api/build.gradle b/api/build.gradle index c955f96f0..92e751d23 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -1,4 +1,4 @@ dependencies { implementation project(':application') - implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'com.google.guava:guava:33.4.0-jre' } \ No newline at end of file diff --git a/api/src/main/java/com/example/aop/MovieSearchRateLimited.java b/api/src/main/java/com/example/aop/MovieSearchRateLimited.java new file mode 100644 index 000000000..f5c3e71f5 --- /dev/null +++ b/api/src/main/java/com/example/aop/MovieSearchRateLimited.java @@ -0,0 +1,11 @@ +package com.example.aop; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface MovieSearchRateLimited { +} diff --git a/api/src/main/java/com/example/aop/RateLimitAspect.java b/api/src/main/java/com/example/aop/RateLimitAspect.java new file mode 100644 index 000000000..c539f623c --- /dev/null +++ b/api/src/main/java/com/example/aop/RateLimitAspect.java @@ -0,0 +1,50 @@ +package com.example.aop; + +import com.example.config.ratelimit.RateLimit; +import com.example.exception.BusinessError; +import com.example.reservation.request.ReservationRequest; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@Aspect +@Component +@RequiredArgsConstructor +public class RateLimitAspect { + + private final RateLimit rateLimit; + + @Around("@annotation(movieSearchRateLimited)") + public Object around(ProceedingJoinPoint joinPoint, MovieSearchRateLimited movieSearchRateLimited) throws Throwable { + + HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); + String ip = request.getRemoteAddr(); + + if (!rateLimit.isMovieSearchAllowed(ip)) { + throw BusinessError.MOVIE_SEARCH_MAX_FIND_ERROR.exception(); + } + + return joinPoint.proceed(); + } + + @Around("@annotation(reservationRateLimited)") + public Object around(ProceedingJoinPoint joinPoint, ReservationRateLimited reservationRateLimited) throws Throwable { + Object[] args = joinPoint.getArgs(); + for (Object arg : args) { + if (arg instanceof com.example.reservation.request.ReservationRequest) { + ReservationRequest request = (ReservationRequest) arg; + if (!rateLimit.isReservationAllowed(request.getMemberId(), request.getScreeningId())) { + throw BusinessError.RESERVATION_RATE_LIMIT_ERROR.exception(); + } + + } + } + + return joinPoint.proceed(); + } +} diff --git a/api/src/main/java/com/example/aop/ReservationRateLimited.java b/api/src/main/java/com/example/aop/ReservationRateLimited.java new file mode 100644 index 000000000..1ccf4b7a1 --- /dev/null +++ b/api/src/main/java/com/example/aop/ReservationRateLimited.java @@ -0,0 +1,11 @@ +package com.example.aop; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface ReservationRateLimited { +} diff --git a/api/src/main/java/com/example/config/advice/ExceptionAdvice.java b/api/src/main/java/com/example/config/advice/ExceptionAdvice.java new file mode 100644 index 000000000..80713e9a1 --- /dev/null +++ b/api/src/main/java/com/example/config/advice/ExceptionAdvice.java @@ -0,0 +1,16 @@ +package com.example.config.advice; + +import com.example.exception.BusinessException; +import com.example.response.ApiResponse; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class ExceptionAdvice { + + @ExceptionHandler(BusinessException.class) + public ApiResponse BusinessExceptionHandler(BusinessException e) { + return ApiResponse.businessException(e.getHttpStatus(), e.getCode(), e.getMessage()); + } + +} diff --git a/api/src/main/java/com/example/config/ratelimit/GuavaRateLimit.java b/api/src/main/java/com/example/config/ratelimit/GuavaRateLimit.java new file mode 100644 index 000000000..a87f204e0 --- /dev/null +++ b/api/src/main/java/com/example/config/ratelimit/GuavaRateLimit.java @@ -0,0 +1,93 @@ +package com.example.config.ratelimit; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.common.util.concurrent.RateLimiter; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Component +public class GuavaRateLimit implements RateLimit { + + private static final double PERMITS_PER_SECOND = 2.0; + private static final int MAX_REQUEST_PER_MINUTE = 50; + + private final Cache blockedIpCache = CacheBuilder.newBuilder() + .expireAfterWrite(1, TimeUnit.HOURS) + .build(); + + private final Cache requestCountCache = CacheBuilder.newBuilder() + .expireAfterWrite(1, TimeUnit.MINUTES) + .build(); + + private final Cache ipRateLimiterCache = CacheBuilder.newBuilder() + .expireAfterAccess(1, TimeUnit.MINUTES) + .build(); + + private final Cache reservationCache = CacheBuilder.newBuilder() + .expireAfterWrite(5, TimeUnit.MINUTES) // 5분 동안 캐시 유지 + .build(); + + @Override + public boolean isMovieSearchAllowed(String ip) { + if (isBlocked(ip)) { + return false; + } + + RateLimiter rateLimiter = getRateLimiter(ip); + if (!rateLimiter.tryAcquire()) { + return false; + } + + int count = incrementRequestCount(ip); + if (count > MAX_REQUEST_PER_MINUTE) { + blockIp(ip); + return false; + } + + return true; + } + + @Override + public boolean isReservationAllowed(Long memberId, Long screeningId) { + + String key = memberId + "_" + screeningId; + + // null이면 예약 내역이 없다는 거니까 + // 데이터를 만들어줘야지 + if (reservationCache.getIfPresent(key) == null) { + reservationCache.put(key, System.currentTimeMillis()); + return true; + } + + return false; + } + + private boolean isBlocked(String ip) { + return blockedIpCache.getIfPresent(ip) != null; + } + + private void blockIp(String ip) { + blockedIpCache.put(ip, Boolean.TRUE); + } + + private RateLimiter getRateLimiter(String ip) { + RateLimiter rateLimiter = ipRateLimiterCache.getIfPresent(ip); + if (rateLimiter == null) { + rateLimiter = RateLimiter.create(PERMITS_PER_SECOND); + ipRateLimiterCache.put(ip, rateLimiter); + } + return rateLimiter; + } + + private int incrementRequestCount(String ip) { + Integer count = requestCountCache.getIfPresent(ip); + if (count == null) { + count = 0; + } + count++; + requestCountCache.put(ip, count); + return count; + } +} diff --git a/api/src/main/java/com/example/config/ratelimit/RateLimit.java b/api/src/main/java/com/example/config/ratelimit/RateLimit.java new file mode 100644 index 000000000..36c195439 --- /dev/null +++ b/api/src/main/java/com/example/config/ratelimit/RateLimit.java @@ -0,0 +1,9 @@ +package com.example.config.ratelimit; + +public interface RateLimit { + + boolean isMovieSearchAllowed(String ip); + + boolean isReservationAllowed(Long memberId, Long screeningId); + +} diff --git a/api/src/main/java/com/example/movie/MovieController.java b/api/src/main/java/com/example/movie/MovieController.java index f3f3b0e4a..b23592fd7 100644 --- a/api/src/main/java/com/example/movie/MovieController.java +++ b/api/src/main/java/com/example/movie/MovieController.java @@ -1,7 +1,9 @@ package com.example.movie; +import com.example.aop.MovieSearchRateLimited; import com.example.movie.request.MovieSearchRequest; import com.example.movie.response.MovieResponse; +import com.example.response.ApiResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; @@ -17,7 +19,8 @@ public class MovieController { private final MovieService movieService; @GetMapping("/v1/movies") - public List getMovies(MovieSearchRequest request) { - return movieService.getMovies(request.toServiceRequest()); + @MovieSearchRateLimited + public ApiResponse> getMovies(MovieSearchRequest request) { + return ApiResponse.ok("영화 목록 조회",movieService.getMovies(request.toServiceRequest())); } } diff --git a/api/src/main/java/com/example/movie/request/MovieSearchRequest.java b/api/src/main/java/com/example/movie/request/MovieSearchRequest.java index ed97b72f2..e589b4ee7 100644 --- a/api/src/main/java/com/example/movie/request/MovieSearchRequest.java +++ b/api/src/main/java/com/example/movie/request/MovieSearchRequest.java @@ -3,6 +3,8 @@ import com.example.movie.dto.GenreDto; import lombok.Getter; +import static com.example.exception.BusinessError.*; + @Getter public class MovieSearchRequest { private String title; @@ -15,10 +17,10 @@ public MovieSearchRequest(String title, String genre) { private void validate() { if (this.title != null && this.title.length() > 225) { - throw new IllegalArgumentException("영화 제목은 225자 이하로 입력해주세요"); + throw MOVIE_SEARCH_TITLE_ERROR.exception(); } if (this.genre != null && !GenreDto.isValidGenre(this.genre)) { - throw new IllegalArgumentException("유효하지않은 장르입니다"); + throw MOVIE_SEARCH_GENRE_ERROR.exception(); } } diff --git a/api/src/main/java/com/example/reservation/ReservationController.java b/api/src/main/java/com/example/reservation/ReservationController.java index 5df5c8eba..4c151804a 100644 --- a/api/src/main/java/com/example/reservation/ReservationController.java +++ b/api/src/main/java/com/example/reservation/ReservationController.java @@ -1,7 +1,9 @@ package com.example.reservation; +import com.example.aop.ReservationRateLimited; import com.example.reservation.request.ReservationRequest; import com.example.reservation.response.ReservationServiceResponse; +import com.example.response.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -14,7 +16,8 @@ public class ReservationController { private final ReservationService reservationService; @PostMapping("/v1/reservation") - public ReservationServiceResponse reserve(@RequestBody ReservationRequest request) { - return reservationService.reserve(request.toServiceRequest()); + @ReservationRateLimited + public ApiResponse reserve(@RequestBody ReservationRequest request) { + return ApiResponse.created("영화예매 성공", reservationService.reserve(request.toServiceRequest())); } } diff --git a/api/src/main/java/com/example/reservation/request/ReservationRequest.java b/api/src/main/java/com/example/reservation/request/ReservationRequest.java index 80d92a60b..cf306e4b4 100644 --- a/api/src/main/java/com/example/reservation/request/ReservationRequest.java +++ b/api/src/main/java/com/example/reservation/request/ReservationRequest.java @@ -4,6 +4,9 @@ import java.util.List; +import static com.example.exception.BusinessError.*; + + @Getter public class ReservationRequest { private Long memberId; @@ -16,7 +19,20 @@ public ReservationRequest(Long memberId, Long screeningId, List seatIds) { this.seatIds = seatIds; } + private void validate() { + if (memberId == null) { + throw USER_LOGIN_ERROR.exception(); + } + if (screeningId == null) { + throw RESERVATION_SCREENING_SELECT_ERROR.exception(); + } + if (seatIds.isEmpty()) { + throw RESERVATION_SEAT_SELECT_ERROR.exception(); + } + } + public ReservationServiceRequest toServiceRequest() { + this.validate(); return ReservationServiceRequest.builder() .memberId(memberId) .screeningId(screeningId) diff --git a/api/src/main/java/com/example/response/ApiResponse.java b/api/src/main/java/com/example/response/ApiResponse.java new file mode 100644 index 000000000..d7ad78a4e --- /dev/null +++ b/api/src/main/java/com/example/response/ApiResponse.java @@ -0,0 +1,35 @@ +package com.example.response; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class ApiResponse { + + private final static int DEFAULT_OK_CODE = 2000; + private final static int DEFAULT_CREATE_CODE = 2001; + + private HttpStatus status; + private int code; + private String message; + private T data; + + public ApiResponse(HttpStatus status, int code, String message, T data) { + this.status = status; + this.code = code; + this.message = message; + this.data = data; + } + + public static ApiResponse ok(String message, T data) { + return new ApiResponse<>(HttpStatus.OK, DEFAULT_OK_CODE, message, data); + } + + public static ApiResponse created(String message, T data) { + return new ApiResponse<>(HttpStatus.CREATED, DEFAULT_CREATE_CODE, message, data); + } + + public static ApiResponse businessException(HttpStatus httpstatus, int code, String message) { + return new ApiResponse<>(httpstatus != null ? httpstatus : HttpStatus.BAD_REQUEST, code, message, null); + } +} diff --git a/api/src/test/java/com/example/config/ratelimit/GuavaRateLimitTest.java b/api/src/test/java/com/example/config/ratelimit/GuavaRateLimitTest.java new file mode 100644 index 000000000..c1b530e9b --- /dev/null +++ b/api/src/test/java/com/example/config/ratelimit/GuavaRateLimitTest.java @@ -0,0 +1,45 @@ +package com.example.config.ratelimit; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class GuavaRateLimitTest { + + private final RateLimit rateLimit = new GuavaRateLimit(); + + @Test + @DisplayName("초당 요청 제한 테스트: 연속 요청 시 per-second RateLimiter가 제한하는지 확인") + void test1() { + String ip = "192.168.1.101"; // 테스트용 IP + + int allowedCount = 0; + int totalRequests = 10; + + for (int i = 0; i < totalRequests; i++) { + if (rateLimit.isAllowed(ip)) { + allowedCount++; + } + } + + assertThat(allowedCount < totalRequests).isTrue(); + assertThat(allowedCount <= 3).isTrue(); + } + + @Test + @DisplayName("1분 내 50회 초과 요청 시 IP 차단 테스트: 51번째 요청부터 차단된다") + void test2() throws InterruptedException { + String ip = "192.168.1.101"; + + for (int i = 0; i < 50; i++) { + assertThat(rateLimit.isAllowed(ip)).isTrue(); + Thread.sleep(1000); + } + + boolean allowed = rateLimit.isAllowed(ip); + assertThat(allowed).isFalse(); + + assertThat(rateLimit.isAllowed(ip)).isFalse(); + } +} \ No newline at end of file diff --git a/api/src/test/java/com/example/reservation/request/ReservationRequestTest.java b/api/src/test/java/com/example/reservation/request/ReservationRequestTest.java new file mode 100644 index 000000000..857560df5 --- /dev/null +++ b/api/src/test/java/com/example/reservation/request/ReservationRequestTest.java @@ -0,0 +1,51 @@ +package com.example.reservation.request; + +import com.example.exception.BusinessException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ReservationRequestTest { + + @Test + @DisplayName("serviceRequest를 생성할때 memberId가 없으면 예외가 던져진다") + void memberId_exception() { + ReservationRequest reservationRequest = new ReservationRequest(null, null, null); + assertThatThrownBy(reservationRequest::toServiceRequest) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("로그인이 필요합니다."); + } + + @Test + @DisplayName("serviceRequest를 생성할때 screeningId가 없으면 예외가 던져진다") + void screeningId_exception() { + ReservationRequest reservationRequest = new ReservationRequest(1L, null, null); + assertThatThrownBy(reservationRequest::toServiceRequest) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("예매할 상영시간을 선택해주세요."); + } + + @Test + @DisplayName("serviceRequest를 생성할때 seats가 비어있으면 예외가 던져진다") + void seatIds_exception() { + ReservationRequest reservationRequest = new ReservationRequest(1L, 1L, List.of()); + assertThatThrownBy(reservationRequest::toServiceRequest) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("예매할 좌석을 선택해주세요"); + } + + @Test + @DisplayName("예외 조건을 모두 만족하면 ReservationServieRequest를 생성한다.") + void success() { + ReservationRequest reservationRequest = new ReservationRequest(1L, 1L, List.of(1L, 2L)); + ReservationServiceRequest serviceRequest = reservationRequest.toServiceRequest(); + + assertThat(serviceRequest.getMemberId()).isEqualTo(1L); + assertThat(serviceRequest.getScreeningId()).isEqualTo(1L); + assertThat(serviceRequest.getSeatIds()).hasSize(2); + } +} \ No newline at end of file diff --git a/application/src/main/java/com/example/aop/DistributedLock.java b/application/src/main/java/com/example/aop/DistributedLock.java new file mode 100644 index 000000000..69f46a5af --- /dev/null +++ b/application/src/main/java/com/example/aop/DistributedLock.java @@ -0,0 +1,16 @@ +package com.example.aop; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DistributedLock { + String key(); + long leaseTime() default 5; + long waitTime() default 5; + TimeUnit timeUnit() default TimeUnit.SECONDS; +} diff --git a/application/src/main/java/com/example/aop/DistributedLockAspect.java b/application/src/main/java/com/example/aop/DistributedLockAspect.java new file mode 100644 index 000000000..0c2202ef5 --- /dev/null +++ b/application/src/main/java/com/example/aop/DistributedLockAspect.java @@ -0,0 +1,62 @@ +package com.example.aop; + +import com.example.reservation.request.ReservationServiceRequest; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Aspect +@Component +@RequiredArgsConstructor +public class DistributedLockAspect { + + private final RedissonClient redissonClient; + + @Around("@annotation(distributedLock)") + public Object around(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable { + + List lockKeys = getLockKeys(joinPoint, distributedLock); + + List locks = lockKeys.stream() + .map(redissonClient::getLock) + .toList(); + + boolean allLocked = false; + + try { + allLocked = redissonClient.getMultiLock(locks.toArray(new RLock[0])) + .tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit()); + + if (!allLocked) { + throw new IllegalArgumentException("해당 좌석은 현재 다른 사용자가 예매를 진행하고 있습니다."); + } + return joinPoint.proceed(); + } finally { + if (allLocked) { + locks.forEach(lock -> { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + }); + } + } + } + + private List getLockKeys(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) { + Object[] args = joinPoint.getArgs(); + for (Object arg : args) { + if (arg instanceof ReservationServiceRequest request) { + return request.getSeatIds().stream() + .map(seatId -> "reservation:screening:" + request.getScreeningId() + ":seat:" + seatId) + .toList(); + } + } + throw new IllegalArgumentException("예매할 좌석 정보가 없습니다."); + } +} diff --git a/application/src/main/java/com/example/exception/BusinessError.java b/application/src/main/java/com/example/exception/BusinessError.java new file mode 100644 index 000000000..d929c3d10 --- /dev/null +++ b/application/src/main/java/com/example/exception/BusinessError.java @@ -0,0 +1,44 @@ +package com.example.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum BusinessError { + // 유저관련 exception 3000 + USER_LOGIN_ERROR(HttpStatus.BAD_REQUEST, 3000, "로그인이 필요합니다."), + USER_NOT_FOUND_ERROR(HttpStatus.BAD_REQUEST,3001, "회원 정보가 없습니다."), + + // 영화관련 exception 4000 ~ 4999 + MOVIE_SEARCH_TITLE_ERROR(HttpStatus.BAD_REQUEST,4000, "영화 제목은 225자 이하로 입력해주세요"), + MOVIE_SEARCH_GENRE_ERROR(HttpStatus.BAD_REQUEST,4001, "유효하지않은 장르입니다"), + MOVIE_SEARCH_MAX_FIND_ERROR(HttpStatus.TOO_MANY_REQUESTS,4002, "잠시 후 다시 시도해주세요"), + + // 예매관련 exception 5000 ~ 5999 + RESERVATION_SCREENING_SELECT_ERROR(HttpStatus.BAD_REQUEST,5000, "예매할 상영시간을 선택해주세요."), + RESERVATION_SEAT_CONTINUOUS_ERROR(HttpStatus.BAD_REQUEST, 5001, "좌석 예매는 연속적인 좌석만 예매 가능합니다."), + RESERVATION_SEAT_MAX_SIZE_ERROR(HttpStatus.BAD_REQUEST, 5002, "5개 이상의 좌석은 예약할 수 없습니다."), + RESERVATION_SEAT_NOT_MATCH_ERROR(HttpStatus.BAD_REQUEST, 5003, "좌석 정보가 일치하지 않습니다."), + RESERVATION_SEAT_TOTAL_COUNT_ERROR(HttpStatus.BAD_REQUEST,5004, "하나의 상영시간에 5좌석이상 예매할 수 없습니다"), + RESERVATION_EXIST_ERROR(HttpStatus.BAD_REQUEST,5005, "이미 예매된 좌석입니다."), + RESERVATION_SEAT_SELECT_ERROR(HttpStatus.BAD_REQUEST, 5006, "예매할 좌석을 선택해주세요"), + RESERVATION_RATE_LIMIT_ERROR(HttpStatus.TOO_MANY_REQUESTS, 5007, "같은 상영시간은 5분후 예매할 수 있습니다."), + + // 상영관련 exception 6000 ~ 6999 + SCREENING_NOT_FOUND_ERROR(HttpStatus.BAD_REQUEST, 6000, "상영 정보가 없습니다."), + ; + + private final HttpStatus httpStatus; + private final int code; + private final String message; + + BusinessError(HttpStatus httpStatus, int code, String message) { + this.httpStatus = httpStatus; + this.code = code; + this.message = message; + } + + public BusinessException exception() { + return new BusinessException(httpStatus, code, message); + } +} diff --git a/application/src/main/java/com/example/exception/BusinessException.java b/application/src/main/java/com/example/exception/BusinessException.java new file mode 100644 index 000000000..57f520620 --- /dev/null +++ b/application/src/main/java/com/example/exception/BusinessException.java @@ -0,0 +1,17 @@ +package com.example.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class BusinessException extends RuntimeException { + + private final HttpStatus httpStatus; + private final int code; + + public BusinessException(HttpStatus httpStatus, int code, String message) { + super(message); + this.httpStatus = httpStatus; + this.code = code; + } +} diff --git a/application/src/main/java/com/example/reservation/ReservationService.java b/application/src/main/java/com/example/reservation/ReservationService.java index 4d60618da..7dd89683f 100644 --- a/application/src/main/java/com/example/reservation/ReservationService.java +++ b/application/src/main/java/com/example/reservation/ReservationService.java @@ -1,5 +1,6 @@ package com.example.reservation; +import com.example.aop.DistributedLock; import com.example.entity.reservation.Reservation; import com.example.entity.reservation.ReservedSeat; import com.example.message.MessageService; @@ -23,6 +24,7 @@ public class ReservationService { private final ReservationRepository reservationRepository; @Transactional + @DistributedLock(key = "reservation:screening:{#request.screeningId}:seat:{#request.seatIds}", leaseTime = 10, waitTime = 5) public ReservationServiceResponse reserve(ReservationServiceRequest request) { ReservationValidationResult validationResult = reservationValidate.validate(request); @@ -50,6 +52,4 @@ private Reservation createReservation(ReservationValidationResult validationResu return reservation; } - - } diff --git a/application/src/main/java/com/example/reservation/request/ReservationServiceRequest.java b/application/src/main/java/com/example/reservation/request/ReservationServiceRequest.java index 70b09ef66..9c84d2cd5 100644 --- a/application/src/main/java/com/example/reservation/request/ReservationServiceRequest.java +++ b/application/src/main/java/com/example/reservation/request/ReservationServiceRequest.java @@ -18,12 +18,4 @@ public ReservationServiceRequest(Long memberId, Long screeningId, List sea this.screeningId = screeningId; this.seatIds = seatIds; } - - public boolean isMemberIdNull() { - return memberId == null; - } - - public boolean isScreeningIdNull() { - return screeningId == null; - } } diff --git a/application/src/main/java/com/example/reservation/validator/ReservationValidate.java b/application/src/main/java/com/example/reservation/validator/ReservationValidate.java index c4f5c2262..55fbde74e 100644 --- a/application/src/main/java/com/example/reservation/validator/ReservationValidate.java +++ b/application/src/main/java/com/example/reservation/validator/ReservationValidate.java @@ -10,12 +10,13 @@ import com.example.repository.reservation.ReservedSeatRepository; import com.example.reservation.request.ReservationServiceRequest; import lombok.RequiredArgsConstructor; -import org.redisson.api.RScript; import org.redisson.api.RedissonClient; import org.springframework.stereotype.Component; import java.util.List; +import static com.example.exception.BusinessError.*; + @Component @RequiredArgsConstructor public class ReservationValidate { @@ -29,41 +30,34 @@ public class ReservationValidate { public ReservationValidationResult validate(ReservationServiceRequest request) { - if (request.isMemberIdNull()) { - throw new IllegalArgumentException("영화 예매시 로그인이 필요합니다."); - } - if (request.isScreeningIdNull()) { - throw new IllegalArgumentException("예매할 상영시간을 선택해주세요."); - } - Member member = getMember(request.getMemberId()); Screening screening = getScreening(request.getScreeningId()); Seats seats = getSeats(request.getSeatIds()); if (!seats.isContinuousSeat()) { - throw new IllegalArgumentException("좌석 예매는 연속적인 좌석만 예매 가능합니다."); + throw RESERVATION_SEAT_CONTINUOUS_ERROR.exception(); } if (seats.isSizeExceedingLimit()) { - throw new IllegalArgumentException("5개 이상의 좌석은 예약할 수 없습니다."); + throw RESERVATION_SEAT_MAX_SIZE_ERROR.exception(); } if (seats.isSeatMatch(request.getSeatIds())) { - throw new IllegalArgumentException("좌석 정보가 일치하지 않습니다."); + throw RESERVATION_SEAT_NOT_MATCH_ERROR.exception(); } List prevReservedSeats = getReservedSeats(member, screening); if (seats.isTotalSeatCountExceeding(prevReservedSeats)) { - throw new IllegalArgumentException("하나의 상영시간에 5좌석이상 예매할 수 없습니다"); + throw RESERVATION_SEAT_TOTAL_COUNT_ERROR.exception(); } - validateAndReserveSeatsInRedis(screening.getId(), request.getSeatIds()); +// validateAndReserveSeatsInRedis(screening.getId(), request.getSeatIds()); -// List existingReservations = reservedSeatRepository.findByScreeningAndSeats(screening, seats.getSeats()); -// if (!existingReservations.isEmpty()) { -// throw new IllegalArgumentException("이미 예매된 좌석입니다."); -// } + List existingReservations = reservedSeatRepository.findByScreeningAndSeats(screening, seats.getSeats()); + if (!existingReservations.isEmpty()) { + throw RESERVATION_EXIST_ERROR.exception(); + } return ReservationValidationResult.builder() .member(member) @@ -77,12 +71,12 @@ private List getReservedSeats(Member member, Screening screening) } private Member getMember(Long memberId) { - return memberRepository.findById(memberId).orElseThrow(() -> new IllegalArgumentException("회원 정보가 없습니다.")); + return memberRepository.findById(memberId).orElseThrow(USER_NOT_FOUND_ERROR::exception); } private Screening getScreening(Long screeningId) { return screeningRepository.findById(screeningId) - .orElseThrow(() -> new IllegalArgumentException("상영 정보가 없습니다.")); + .orElseThrow(SCREENING_NOT_FOUND_ERROR::exception); } private Seats getSeats(List seatsIds) { @@ -90,33 +84,33 @@ private Seats getSeats(List seatsIds) { return new Seats(seatRepository.findAllById(seatsIds)); } - private void validateAndReserveSeatsInRedis(Long screeningId, List seatIds) { - String redisKey = "screening:" + screeningId + ":seats"; - - // Lua 스크립트: 좌석 상태를 확인하고, 예약되지 않은 경우 예약 상태로 변경 - String luaScript = - "for i, seatId in ipairs(ARGV) do " + - " if redis.call('HGET', KEYS[1], seatId) == 'true' then " + // 이미 예약된 좌석 확인 - " return 0; " + - " end; " + - "end; " + - "for i, seatId in ipairs(ARGV) do " + - " redis.call('HSET', KEYS[1], seatId, 'true'); " + // 좌석 상태를 예약으로 변경 - "end; " + - "return 1;"; - - // Redisson의 Lua 실행 API 호출 - Long result = redissonClient.getScript().eval( - RScript.Mode.READ_WRITE, - luaScript, - RScript.ReturnType.INTEGER, - List.of(redisKey), // Redis 키 - seatIds.stream().map(String::valueOf).toArray() // ARGV: 좌석 ID 리스트 - ); - - // Lua 스크립트 결과가 0이면 좌석 예약 실패 처리 - if (result == 0) { - throw new IllegalArgumentException("이미 예매된 좌석입니다."); - } - } +// private void validateAndReserveSeatsInRedis(Long screeningId, List seatIds) { +// String redisKey = "screening:" + screeningId + ":seats"; +// +// // Lua 스크립트: 좌석 상태를 확인하고, 예약되지 않은 경우 예약 상태로 변경 +// String luaScript = +// "for i, seatId in ipairs(ARGV) do " + +// " if redis.call('HGET', KEYS[1], seatId) == 'true' then " + // 이미 예약된 좌석 확인 +// " return 0; " + +// " end; " + +// "end; " + +// "for i, seatId in ipairs(ARGV) do " + +// " redis.call('HSET', KEYS[1], seatId, 'true'); " + // 좌석 상태를 예약으로 변경 +// "end; " + +// "return 1;"; +// +// // Redisson의 Lua 실행 API 호출 +// Long result = redissonClient.getScript().eval( +// RScript.Mode.READ_WRITE, +// luaScript, +// RScript.ReturnType.INTEGER, +// List.of(redisKey), // Redis 키 +// seatIds.stream().map(String::valueOf).toArray() // ARGV: 좌석 ID 리스트 +// ); +// +// // Lua 스크립트 결과가 0이면 좌석 예약 실패 처리 +// if (result == 0) { +// throw new IllegalArgumentException("이미 예매된 좌석입니다."); +// } +// } } diff --git a/application/src/test/java/com/example/reservation/ReservationServiceTest.java b/application/src/test/java/com/example/reservation/ReservationServiceTest.java index 92bdbfa1b..1764705e8 100644 --- a/application/src/test/java/com/example/reservation/ReservationServiceTest.java +++ b/application/src/test/java/com/example/reservation/ReservationServiceTest.java @@ -3,6 +3,7 @@ import com.example.entity.member.Member; import com.example.entity.movie.*; import com.example.entity.reservation.ReservedSeat; +import com.example.exception.BusinessException; import com.example.repository.member.MemberRepository; import com.example.repository.movie.MovieRepository; import com.example.repository.movie.ScreeningRepository; @@ -54,26 +55,6 @@ void tearDown() { memberRepository.deleteAll(); } - @Test - @DisplayName("memberId가 null이면 예외가 던져진다.") - void reservation_memberId_null_exception() { - ReservationServiceRequest request = ReservationServiceRequest.builder().build(); - - assertThatThrownBy(() -> { reservationService.reserve(request); }) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("영화 예매시 로그인이 필요합니다."); - } - - @Test - @DisplayName("screeningId가 null이면 예외가 던져진다.") - void reservation_screeningId_null_exception() { - ReservationServiceRequest request = ReservationServiceRequest.builder().memberId(1L).build(); - - assertThatThrownBy(() -> { reservationService.reserve(request); }) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("예매할 상영시간을 선택해주세요."); - } - @Test @DisplayName("회원정보가 없으면 예외가 던져집니다.") @@ -82,7 +63,7 @@ void reservation_user_exception() { memberRepository.save(member); assertThatThrownBy(() -> { reservationService.reserve(new ReservationServiceRequest(2L, 1L, List.of(1L, 2L))); }) - .isInstanceOf(IllegalArgumentException.class) + .isInstanceOf(BusinessException.class) .hasMessageContaining("회원 정보가 없습니다."); } @@ -106,7 +87,7 @@ void reservation_screening_exception() { screeningRepository.save(screening); assertThatThrownBy(() -> { reservationService.reserve(new ReservationServiceRequest(member.getId(), 2L, List.of(1L, 2L))); }) - .isInstanceOf(IllegalArgumentException.class) + .isInstanceOf(BusinessException.class) .hasMessageContaining("상영 정보가 없습니다."); } @@ -127,7 +108,7 @@ void reservation_seat_exception() { screeningRepository.save(screening); assertThatThrownBy(() -> { reservationService.reserve(new ReservationServiceRequest(member.getId(), screening.getId(), List.of(1000L, 10001L))); }) - .isInstanceOf(IllegalArgumentException.class) + .isInstanceOf(BusinessException.class) .hasMessageContaining("좌석 정보가 일치하지 않습니다."); } @@ -146,8 +127,8 @@ void reservation_seat_max_five_exception() { seatRepository.saveAll(seats); screeningRepository.save(screening); reservationService.reserve(new ReservationServiceRequest(member.getId(), screening.getId(), List.of(seats.get(0).getId(), seats.get(1).getId()))); - assertThatThrownBy(() -> { reservationService.reserve(new ReservationServiceRequest(member.getId(), screening.getId(), List.of(seats.get(2).getId(), seats.get(3).getId(),seats.get(4).getId(), seats.get(5).getId()))); }) - .isInstanceOf(IllegalArgumentException.class) + assertThatThrownBy(() -> { reservationService.reserve(new ReservationServiceRequest(member.getId(), screening.getId(), List.of(seats.get(5).getId(), seats.get(6).getId(), seats.get(7).getId(), seats.get(8).getId()))); }) + .isInstanceOf(BusinessException.class) .hasMessageContaining("하나의 상영시간에 5좌석이상 예매할 수 없습니다"); } @@ -171,7 +152,7 @@ void reservation_duplication_exception() { assertThatThrownBy(() -> { reservationService.reserve(new ReservationServiceRequest(member.getId(), screening.getId(), List.of(seats.get(0).getId(), seats.get(1).getId()))); }) - .isInstanceOf(IllegalArgumentException.class) + .isInstanceOf(BusinessException.class) .hasMessageContaining("이미 예매된 좌석입니다."); } diff --git a/build.gradle b/build.gradle index e64d27637..0267a6065 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,8 @@ subprojects { implementation 'org.springframework.boot:spring-boot-starter' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-aop' + implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'com.github.ben-manes.caffeine:caffeine' diff --git a/http/ReservationController.http b/http/ReservationController.http index 7b4fcce95..ab658fb9c 100644 --- a/http/ReservationController.http +++ b/http/ReservationController.http @@ -4,5 +4,5 @@ Content-Type: application/json { "screeningId": 12, "memberId": 2, - "seatIds": [72, 73, 74] + "seatIds": [77, 78] } \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 82b64308a..686ab63ab 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,3 +1,2 @@ rootProject.name = 'redis_1st' include 'api', 'application', 'domain' -