diff --git a/module-app/src/main/java/module/config/AppRedisConfig.java b/module-app/src/main/java/module/config/AppRedisConfig.java new file mode 100644 index 000000000..3807c5333 --- /dev/null +++ b/module-app/src/main/java/module/config/AppRedisConfig.java @@ -0,0 +1,24 @@ +package module.config; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.redisson.spring.data.connection.RedissonConnectionFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; + +@Configuration +public class AppRedisConfig { + + @Bean + StringRedisTemplate stringRedisTemplate( RedisConnectionFactory connectionFactory) { + return new StringRedisTemplate(connectionFactory); + } + +} diff --git a/module-app/src/main/java/module/config/ratelimit/RateLimitWith.java b/module-app/src/main/java/module/config/ratelimit/RateLimitWith.java new file mode 100644 index 000000000..d89a19c5f --- /dev/null +++ b/module-app/src/main/java/module/config/ratelimit/RateLimitWith.java @@ -0,0 +1,16 @@ +package module.config.ratelimit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import module.config.ratelimit.limiters.RateLimiter; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RateLimitWith { + Class rateLimiter(); + boolean postProcess() default false; + boolean preProcess() default true; +} diff --git a/module-app/src/main/java/module/config/ratelimit/RateLimiterAop.java b/module-app/src/main/java/module/config/ratelimit/RateLimiterAop.java new file mode 100644 index 000000000..04a69ec67 --- /dev/null +++ b/module-app/src/main/java/module/config/ratelimit/RateLimiterAop.java @@ -0,0 +1,56 @@ +package module.config.ratelimit; + +import java.lang.reflect.Method; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import lombok.RequiredArgsConstructor; +import module.config.ratelimit.limiters.RateLimiter; +import module.config.ratelimit.limiters.RateLimiterFactory; + +@Aspect +@Component +@RequiredArgsConstructor +public class RateLimiterAop { + + private final RateLimiterFactory rateLimiterFactory; + + @Around("@annotation(module.config.ratelimit.RateLimitWith)") + public Object rateLimiter(final ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature)joinPoint.getSignature(); + Method method = signature.getMethod(); + + // 설정된 RateLimiter 조회 + RateLimitWith rateLimitWith = method.getAnnotation(RateLimitWith.class); + + // HttpServletRequest 조회 및 캐싱 + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(attributes.getRequest()); + + RateLimiter rateLimiter = rateLimiterFactory.getRateLimiter(rateLimitWith.rateLimiter()); + + if(rateLimitWith.preProcess()){ + if(!rateLimiter.preCheck(joinPoint.getArgs(), wrappedRequest)){ + return false; + } + } + + Object result = joinPoint.proceed(); + + ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(attributes.getResponse()); + if(rateLimitWith.postProcess()){ + rateLimiter.postCheck(joinPoint.getArgs(), wrappedResponse); + } + + return result; + } +} diff --git a/module-app/src/main/java/module/config/ratelimit/limiters/RateLimiter.java b/module-app/src/main/java/module/config/ratelimit/limiters/RateLimiter.java new file mode 100644 index 000000000..7b848efc7 --- /dev/null +++ b/module-app/src/main/java/module/config/ratelimit/limiters/RateLimiter.java @@ -0,0 +1,9 @@ +package module.config.ratelimit.limiters; + +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +public interface RateLimiter { + boolean preCheck(Object[] args, ContentCachingRequestWrapper request); + default void postCheck(Object[] args, ContentCachingResponseWrapper response){} +} diff --git a/module-app/src/main/java/module/config/ratelimit/limiters/RateLimiterFactory.java b/module-app/src/main/java/module/config/ratelimit/limiters/RateLimiterFactory.java new file mode 100644 index 000000000..3772db1bb --- /dev/null +++ b/module-app/src/main/java/module/config/ratelimit/limiters/RateLimiterFactory.java @@ -0,0 +1,18 @@ +package module.config.ratelimit.limiters; + +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class RateLimiterFactory { + + private final ApplicationContext applicationContext; + + public RateLimiter getRateLimiter(Class T){ + return applicationContext.getBean(T); + } + +} diff --git a/module-app/src/main/java/module/config/ratelimit/limiters/ShowingSearchRateLimiter.java b/module-app/src/main/java/module/config/ratelimit/limiters/ShowingSearchRateLimiter.java new file mode 100644 index 000000000..58fbac5ac --- /dev/null +++ b/module-app/src/main/java/module/config/ratelimit/limiters/ShowingSearchRateLimiter.java @@ -0,0 +1,72 @@ +package module.config.ratelimit.limiters; + +import java.util.Collections; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; +import org.springframework.web.util.ContentCachingRequestWrapper; + +import exception.showing.TooManyRequestException; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ShowingSearchRateLimiter implements module.config.ratelimit.limiters.RateLimiter { + + private static final String LIMIT_PER_SECOND = "2"; + private static final String LIMIT_PER_MINUTE = "50"; + private static final DefaultRedisScript LUA_SCRIPT = new DefaultRedisScript( + """ + local prefix = KEYS[1] + local ip = ARGV[1] + local LIMIT_PER_SECOND = tonumber(ARGV[2]) + local LIMIT_PER_MINUTE = tonumber(ARGV[3]) + + local blocked_ip_key = prefix .. ':BlockedIp:' .. ip + local request_counter_key = prefix .. ':PerMinuteRequestCounter:' .. ip + local per_second_counter_key = prefix .. ':PerSecondRequestCounter:' .. ip + + if redis.call('EXISTS', blocked_ip_key) == 1 then + return false + end + + local per_second_count = redis.call('INCR', per_second_counter_key) + if per_second_count == 1 then + redis.call('EXPIRE', per_second_counter_key, 1) end + if per_second_count > LIMIT_PER_SECOND then + return false + end + + local current_count = redis.call('INCR', request_counter_key) + if current_count == 1 then + redis.call('EXPIRE', request_counter_key, 60) + end + if current_count > LIMIT_PER_MINUTE then + redis.call('SET', blocked_ip_key, 1) + redis.call('EXPIRE', blocked_ip_key, 3600) + return false + end + + return true + """, Boolean.class); + + private final StringRedisTemplate redisTemplate; + private final String KEY_PREFIX = "hanghaeho:showingSearch"; + + @Override + public boolean preCheck(Object[] args, ContentCachingRequestWrapper request){ + String ip = request.getRemoteAddr(); + boolean isAllowed = Boolean.TRUE.equals(redisTemplate.execute( + LUA_SCRIPT, + Collections.singletonList(KEY_PREFIX), + ip, LIMIT_PER_SECOND, LIMIT_PER_MINUTE + )); + + if(!isAllowed){ + throw new TooManyRequestException(); + } + + return true; + } +} diff --git a/module-app/src/main/java/module/config/ratelimit/limiters/TicketReservationRateLimiter.java b/module-app/src/main/java/module/config/ratelimit/limiters/TicketReservationRateLimiter.java new file mode 100644 index 000000000..07452d443 --- /dev/null +++ b/module-app/src/main/java/module/config/ratelimit/limiters/TicketReservationRateLimiter.java @@ -0,0 +1,70 @@ +package module.config.ratelimit.limiters; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import dto.ticket.TicketReservationRequest; +import exception.ticket.NoReservationInfoException; +import exception.ticket.TwoManyReservationRequestException; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class TicketReservationRateLimiter implements RateLimiter { + + private final String KEY_PREFIX = "hanghaeho:ticket_reservation_complete:"; + private final RedisTemplate redisTemplate; + + @Override + public boolean preCheck(Object[] args, ContentCachingRequestWrapper request) { + TicketReservationRequest ticketReservationRequest = getTicketReservationRequest(args); + Long showingId = ticketReservationRequest.getShowingId(); + String username = ticketReservationRequest.getUsername(); + + if(isBlocked(username,showingId)){ + throw new TwoManyReservationRequestException(); + } else { + return true; + } + } + + @Override + public void postCheck(Object[] args, ContentCachingResponseWrapper response) { + if(response.getStatus() != HttpStatus.OK.value()){ + return; + } + + TicketReservationRequest ticketReservationRequest = getTicketReservationRequest(args); + + Long showingId = ticketReservationRequest.getShowingId(); + String username = ticketReservationRequest.getUsername(); + + addBlock(username, showingId); + } + + public boolean isBlocked(String user, Long showingId) { + String key = keyFormatter(user, showingId); + return redisTemplate.opsForValue().get(key) != null; + } + + public void addBlock(String username, Long showingId) { + String key = keyFormatter(username, showingId); + redisTemplate.opsForValue().set(key, "complete", 300000); + } + + public TicketReservationRequest getTicketReservationRequest(Object[] args){ + for(Object o : args){ + if(o instanceof TicketReservationRequest){ + return (TicketReservationRequest) o; + } + } + throw new NoReservationInfoException(); + } + + public String keyFormatter(String username, Long showingId){ + return KEY_PREFIX + username + ":" + showingId; + } +} diff --git a/module-app/src/main/java/module/controller/ShowingController.java b/module-app/src/main/java/module/controller/ShowingController.java index 44440d02f..0245f48b6 100644 --- a/module-app/src/main/java/module/controller/ShowingController.java +++ b/module-app/src/main/java/module/controller/ShowingController.java @@ -14,6 +14,8 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Size; import lombok.RequiredArgsConstructor; +import module.config.ratelimit.limiters.ShowingSearchRateLimiter; +import module.config.ratelimit.RateLimitWith; import module.service.showing.ShowingService; @RestController @@ -24,6 +26,7 @@ public class ShowingController { private final ShowingService showingService; @GetMapping("/all") + @RateLimitWith(rateLimiter = ShowingSearchRateLimiter.class) public ResponseEntity> getTodayShowing( @Valid @Size(max = 10, message = "제목 최대 길이는 255자 이내 입니다.") @Nullable @RequestParam String title, diff --git a/module-app/src/main/java/module/controller/TicketController.java b/module-app/src/main/java/module/controller/TicketController.java index 9f25dc1eb..830dc90fd 100644 --- a/module-app/src/main/java/module/controller/TicketController.java +++ b/module-app/src/main/java/module/controller/TicketController.java @@ -17,6 +17,8 @@ import jakarta.validation.constraints.Pattern; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import module.config.ratelimit.RateLimitWith; +import module.config.ratelimit.limiters.TicketReservationRateLimiter; import module.service.ticket.TicketService; @Slf4j @@ -43,12 +45,13 @@ public ResponseEntity> getUserTicket( } @PostMapping(value = "/reservation") + @RateLimitWith(rateLimiter = TicketReservationRateLimiter.class, postProcess = true) public ResponseEntity reservation( @RequestBody TicketReservationRequest request ) { Long showingId = request.getShowingId(); String username = request.getUsername(); List ticketList = request.getTicketList(); - return ResponseEntity.ok(ticketService.reservationWithFunctional(showingId, username, ticketList)); + return ResponseEntity.ok(ticketService.reservation(showingId, username, ticketList)); } } diff --git a/module-app/src/main/java/module/exception/ControllerAdvice.java b/module-app/src/main/java/module/exception/ControllerAdvice.java index 73782a5ad..85331c918 100644 --- a/module-app/src/main/java/module/exception/ControllerAdvice.java +++ b/module-app/src/main/java/module/exception/ControllerAdvice.java @@ -9,11 +9,13 @@ import exception.common.TryLockFailedException; import exception.showing.ShowingNotFoundException; +import exception.showing.TooManyRequestException; import exception.ticket.InvalidAgeForMovieException; import exception.ticket.InvalidSeatConditionException; import exception.ticket.InvalidTicketException; import exception.ticket.NotOnSaleTicketException; import exception.ticket.TooManyReservationException; +import exception.ticket.TwoManyReservationRequestException; import exception.user.UserNotFoundException; import lombok.extern.slf4j.Slf4j; @@ -83,4 +85,18 @@ protected ResponseEntity tryLockFailedException(TryLockFailedException e return ResponseEntity.status(HttpStatus.NO_CONTENT) .body(e.getMessage()); } + + @ExceptionHandler(TooManyRequestException.class) + @ResponseStatus(HttpStatus.NO_CONTENT) + protected ResponseEntity tooManyRequestException(TooManyRequestException e){ + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(e.getMessage()); + } + + @ExceptionHandler(TwoManyReservationRequestException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + protected ResponseEntity tooManyReservationRequestException(TwoManyReservationRequestException e){ + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(e.getMessage()); + } } diff --git a/module-app/src/test/java/module/controller/ShowingControllerTest.java b/module-app/src/test/java/module/controller/ShowingControllerTest.java new file mode 100644 index 000000000..b31f10cd8 --- /dev/null +++ b/module-app/src/test/java/module/controller/ShowingControllerTest.java @@ -0,0 +1,59 @@ +package module.controller; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.stream.IntStream; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +public class ShowingControllerTest { + + private final MockMvc mockMvc; + + @Autowired + public ShowingControllerTest(MockMvc mockMvc) { + this.mockMvc = mockMvc; + } + + @Test + public void 의존성주입() { + assertThat(mockMvc).isNotNull(); + } + + @Test + public void 리미터_51번째_조회() throws Exception { + String reqURI = "/api/v1/showings/all?title=말할 수 없는 비밀&genreId=2"; + for (int i = 0; i < 50; i++) { + mockMvc.perform(get(reqURI)) + .andExpect(status().is(HttpStatus.OK.value())); + Thread.sleep(500); + } + mockMvc.perform(get(reqURI)) + .andExpect(status().is(HttpStatus.BAD_REQUEST.value())); + } + + @Test + public void 리미터_초당3회() throws Exception { + String reqURI = "/api/v1/showings/all?title=말할 수 없는 비밀&genreId=2"; + IntStream.range(0,2).parallel() + .forEach(i->{ + try { + mockMvc.perform(get(reqURI)) + .andExpect(status().is(HttpStatus.OK.value())); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + mockMvc.perform(get(reqURI)) + .andExpect(status().is(HttpStatus.BAD_REQUEST.value())); + } +} diff --git a/module-app/src/test/java/module/controller/TicketControllerTest.java b/module-app/src/test/java/module/controller/TicketControllerTest.java index 9140dca5f..70c8da658 100644 --- a/module-app/src/test/java/module/controller/TicketControllerTest.java +++ b/module-app/src/test/java/module/controller/TicketControllerTest.java @@ -1,33 +1,49 @@ package module.controller; import static org.assertj.core.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.test.annotation.Rollback; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.RequestBuilder; + +import com.fasterxml.jackson.databind.ObjectMapper; import dto.ticket.TicketResponse; @SpringBootTest -@Rollback +@AutoConfigureMockMvc public class TicketControllerTest { private final TicketController ticketController; + private final MockMvc mockMvc; + private final ObjectMapper objectMapper; @Autowired - public TicketControllerTest(TicketController ticketController) { + public TicketControllerTest(TicketController ticketController, MockMvc mockMvc, ObjectMapper objectMapper) { this.ticketController = ticketController; + this.mockMvc = mockMvc; + this.objectMapper = objectMapper; } @Test @DisplayName("[조회] - 기능") - public void getAllTicketTest(){ + public void getAllTicketTest() { //given Long showingId = 7760L; @@ -39,4 +55,34 @@ public void getAllTicketTest(){ assertThat(allTicket.getBody().size()).isEqualTo(25); } + + @Test + @DisplayName("limiter - continuous request with same showing") + public void 연속두번() throws Exception { + // given + // 요청 본문 데이터 생성 + Map requestBody = new HashMap<>(); + requestBody.put("username", "dbdb1114"); + requestBody.put("showingId", 9501); + + // ticketList 구성 + Map ticket = new HashMap<>(); + ticket.put("ticketId", 58575); + requestBody.put("ticketList", Collections.singletonList(ticket)); + + // json 문자열로 변환 + String jsonRequest = objectMapper.writeValueAsString(requestBody); + + // when + mockMvc.perform(post("/api/v1/ticket/reservation") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonRequest)) + .andExpect(status().is(HttpStatus.OK.value())); + + // then + mockMvc.perform(post("/api/v1/ticket/reservation") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonRequest)) + .andExpect(status().is(HttpStatus.BAD_REQUEST.value())); + } } diff --git a/module-core/src/main/java/exception/showing/TooManyRequestException.java b/module-core/src/main/java/exception/showing/TooManyRequestException.java new file mode 100644 index 000000000..e79ab9a72 --- /dev/null +++ b/module-core/src/main/java/exception/showing/TooManyRequestException.java @@ -0,0 +1,9 @@ +package exception.showing; + +public class TooManyRequestException extends RuntimeException{ + private static final String DEFAULT_MESSAGE = "요청을 너무 많이 보냈습니다."; + public TooManyRequestException() { + super(DEFAULT_MESSAGE); + } + +} diff --git a/module-core/src/main/java/exception/ticket/NoReservationInfoException.java b/module-core/src/main/java/exception/ticket/NoReservationInfoException.java new file mode 100644 index 000000000..eb432a460 --- /dev/null +++ b/module-core/src/main/java/exception/ticket/NoReservationInfoException.java @@ -0,0 +1,8 @@ +package exception.ticket; + +public class NoReservationInfoException extends RuntimeException{ + private static final String DEFAULT_MESSAGE = "예매정보가 없습니다."; + public NoReservationInfoException() { + super(DEFAULT_MESSAGE); + } +} diff --git a/module-core/src/main/java/exception/ticket/TwoManyReservationRequestException.java b/module-core/src/main/java/exception/ticket/TwoManyReservationRequestException.java new file mode 100644 index 000000000..7fdc86c58 --- /dev/null +++ b/module-core/src/main/java/exception/ticket/TwoManyReservationRequestException.java @@ -0,0 +1,8 @@ +package exception.ticket; + +public class TwoManyReservationRequestException extends RuntimeException { + private static final String DEFAULT_MESSAGE = "5분 뒤 다시 시도해주세요"; + public TwoManyReservationRequestException() { + super(DEFAULT_MESSAGE); + } +} diff --git a/module-service/src/main/java/module/lock/aop/AopForTransaction.java b/module-service/src/main/java/module/lock/aop/AopForTransaction.java index 3d6db55e8..fc51df48a 100644 --- a/module-service/src/main/java/module/lock/aop/AopForTransaction.java +++ b/module-service/src/main/java/module/lock/aop/AopForTransaction.java @@ -10,7 +10,6 @@ */ @Component public class AopForTransaction { - private AopForTransaction() {} @Transactional(propagation = Propagation.REQUIRES_NEW) protected Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable { return joinPoint.proceed(); diff --git a/module-service/src/main/java/module/lock/aop/DistributedLockAop.java b/module-service/src/main/java/module/lock/aop/DistributedLockAop.java index 36501dff0..389cedc57 100644 --- a/module-service/src/main/java/module/lock/aop/DistributedLockAop.java +++ b/module-service/src/main/java/module/lock/aop/DistributedLockAop.java @@ -4,6 +4,7 @@ import java.util.Arrays; import java.util.Collection; import java.util.List; +import java.util.concurrent.TimeUnit; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; @@ -29,7 +30,7 @@ public class DistributedLockAop { @Around("@annotation(module.lock.aop.DistributedLock)") public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable { - MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + MethodSignature signature = (MethodSignature)joinPoint.getSignature(); Method method = signature.getMethod(); DistributedLock distributedLock = method.getAnnotation(DistributedLock.class); @@ -39,14 +40,7 @@ public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable { // Lock 대상이 여러 개일 경우를 대비하여 // Collection이나 Array로 변환하여 Lock 점유 - Object[] keysArr; - if(keys instanceof Collection){ - keysArr = ((Collection)keys).toArray(); - } else if( keys.getClass().isArray() ){ - keysArr = (Object[]) keys; - } else { - keysArr = new Object[] {keys}; - } + Object[] keysArr = convertToArray(keys); // key 배열만큼 락 생성 List lockList = Arrays.stream(keysArr) @@ -54,45 +48,58 @@ public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable { .map(key -> redissonClient.getLock(REDISSON_LOCK_PREFIX + keyPrefix + key)) .toList(); - try{ + try { // 락 점유 하나라도 점유 실패 시 TryLockFailedException - boolean allAvailable = true; - for (RLock lock : lockList) { - try{ - if(!lock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(),distributedLock.timeUnit())){ - allAvailable = false; - break; - } - } catch (InterruptedException e){ - allAvailable = false; - break; - } - } + boolean allAvailable = acquireLock(lockList, distributedLock.waitTime(), distributedLock.leaseTime(), + distributedLock.timeUnit()); - // 배열내 모든 락이 점유 실패 - if(!allAvailable){ - // 순차적으로 락을 해제하도록 변경 - for (RLock lock : lockList) { - if (lock.isHeldByCurrentThread()) { - lock.unlock(); - } - } + // 배열내 모든 락이 점유 실패시 + // 점유한 락 해제 + if (!allAvailable) { + leaseAllLock(lockList); throw new TryLockFailedException(); } return aopForTransaction.proceed(joinPoint); } finally { // 메소드 종료 후 모든 락 점유 해제 - lockList.forEach(lock -> { - if (lock.isHeldByCurrentThread()) { - try { - lock.unlock(); - } catch (IllegalMonitorStateException e) { - log.warn("Lock was already released: {}", lock.getName()); - } + leaseAllLock(lockList); + } + + } + + private void leaseAllLock(List lockList) { + for (RLock lock : lockList) { + if (lock.isHeldByCurrentThread()) { + try { + lock.unlock(); + } catch (IllegalMonitorStateException e) { + log.warn("Lock was already released: {}", lock.getName()); } - }); + } } + } + private boolean acquireLock(List lockList, Long waitTIme, Long leaseTime, TimeUnit timeUnit) { + for (RLock lock : lockList) { + try { + if (!lock.tryLock(waitTIme, leaseTime, timeUnit)) { + return false; + } + } catch (InterruptedException e) { + return false; + } + } + return true; + } + + private Object[] convertToArray(Object keys) { + if (keys instanceof Collection) { + return ((Collection)keys).toArray(); + } else if (keys.getClass().isArray()) { + return (Object[])keys; + } else { + return new Object[] {keys}; + } } } diff --git a/module-service/src/main/java/module/lock/functional/FunctionalDistributedLock.java b/module-service/src/main/java/module/lock/functional/FunctionalDistributedLock.java index 540ce24d9..82dd8d556 100644 --- a/module-service/src/main/java/module/lock/functional/FunctionalDistributedLock.java +++ b/module-service/src/main/java/module/lock/functional/FunctionalDistributedLock.java @@ -32,43 +32,45 @@ public void executeLock(String keyPrefix, List keys, Runnable try{ // 락 점유 하나라도 점유 실패 시 TryLockFailedException - boolean allAvailable = true; - for (RLock lock : lockList) { - try{ - if(!lock.tryLock(LOCK_WAIT_TIME, LOCK_LEASE_TIME,TimeUnit.SECONDS)){ - allAvailable = false; - break; - } - } catch (InterruptedException e){ - allAvailable = false; - break; - } - } + boolean allAvailable = acquireLock(lockList, LOCK_WAIT_TIME, LOCK_LEASE_TIME, TimeUnit.SECONDS); - // 배열내 모든 락이 점유 실패 + // 배열내 모든 락이 점유 실패시 + // 점유한 락 모두 해제 if(!allAvailable){ - // 순차적으로 락을 해제하도록 변경 - for (RLock lock : lockList) { - if (lock.isHeldByCurrentThread()) { - lock.unlock(); - } - } + leaseAllLock(lockList); throw new TryLockFailedException(); } functionalForTransaction.run(runnable::run); } finally { // 메소드 종료 후 모든 락 점유 해제 - lockList.forEach(lock -> { - if (lock.isHeldByCurrentThread()) { - try { - lock.unlock(); - } catch (IllegalMonitorStateException e) { - log.warn("Lock was already released: {}", lock.getName()); - } + leaseAllLock(lockList); + } + } + + private void leaseAllLock(List lockList) { + for (RLock lock : lockList) { + if (lock.isHeldByCurrentThread()) { + try { + lock.unlock(); + } catch (IllegalMonitorStateException e) { + log.warn("Lock was already released: {}", lock.getName()); } - }); + } + } + } + + private boolean acquireLock(List lockList, Long waitTIme, Long leaseTime, TimeUnit timeUnit) { + for (RLock lock : lockList) { + try { + if (!lock.tryLock(waitTIme, leaseTime, timeUnit)) { + return false; + } + } catch (InterruptedException e) { + return false; + } } + return true; } } diff --git a/module-service/src/main/java/module/lock/functional/FunctionalForTransaction.java b/module-service/src/main/java/module/lock/functional/FunctionalForTransaction.java index a43267eb1..80345b718 100644 --- a/module-service/src/main/java/module/lock/functional/FunctionalForTransaction.java +++ b/module-service/src/main/java/module/lock/functional/FunctionalForTransaction.java @@ -9,8 +9,6 @@ */ @Component public class FunctionalForTransaction { - private FunctionalForTransaction() {} - @Transactional(propagation = Propagation.REQUIRES_NEW) protected void run(Runnable runnable){ runnable.run(); diff --git a/module-service/src/main/java/module/service/ticket/TicketService.java b/module-service/src/main/java/module/service/ticket/TicketService.java index 1fb48d16f..41b0bafed 100644 --- a/module-service/src/main/java/module/service/ticket/TicketService.java +++ b/module-service/src/main/java/module/service/ticket/TicketService.java @@ -49,7 +49,6 @@ public class TicketService { private final SalesRepository salesRepository; private final SeatsRepository seatsRepository; - public List getAllTicket(Long showingId) { // 존재하는 상영정보인지 확인 Optional showingOptional = showingRepository.findById(showingId); @@ -81,40 +80,61 @@ public List getUserTicket(String username) { return ticketList; } + public String reservation(Long showingId, String username, List ticketDtoList) { + // 티켓 및 사용자 유효성 검증 + validateReservation(showingId, username, ticketDtoList); + + return reservationWithFunctionalLock(username, ticketDtoList); + // return reservationWithAOPLock(username, ticketDtoList); + } + @DistributedLock( keyPrefix = "TICKET", key = "#ticketDtoList.?[ticketId != null].![ticketId].toArray()" ) - public String reservationWithAOP(Long showingId, String username, List ticketDtoList) { - // 예외처리 [ 존재하지 않는 사용자 ] - Optional optionalUser = userRepository.findByUsername(username); - if (optionalUser.isEmpty()) { - throw new UserNotFoundException(); - } - User user = optionalUser.get(); - validateAndReserve(showingId, user, ticketDtoList); + public String reservationWithAOPLock(String username, List ticketDtoList) { + List ticketList = ticketRepository.findAllByTicketIdIn( + ticketDtoList.stream().map(TicketDTO::getTicketId).toList()); + User user = userRepository.findByUsername(username).get(); + // 최종연산 [ 결제 완료 처리 ] + for (Ticket ticket : ticketList) { + ticket.setTicketStatus(TicketStatus.RESERVED); + Sales sales = Sales.builder().price(9000) + .user(user).ticket(ticket) + .createBy("system").build(); + salesRepository.save(sales); + } return "success"; } - public String reservationWithFunctional(Long showingId, String username, List ticketDtoList) { + public String reservationWithFunctionalLock(String username, List ticketDtoList) { List keys = ticketDtoList.stream().map(TicketDTO::getTicketId).toList(); - functionalDistributedLock.executeLock("TICKET:", keys, () -> { - // 예외처리 [ 존재하지 않는 사용자 ] - Optional optionalUser = userRepository.findByUsername(username); - if (optionalUser.isEmpty()) { - throw new UserNotFoundException(); + List ticketList = ticketRepository.findAllByTicketIdIn( + ticketDtoList.stream().map(TicketDTO::getTicketId).toList()); + User user = userRepository.findByUsername(username).get(); + + // 최종연산 [ 결제 완료 처리 ] + for (Ticket ticket : ticketList) { + ticket.setTicketStatus(TicketStatus.RESERVED); + Sales sales = Sales.builder().price(9000) + .user(user).ticket(ticket) + .createBy("system").build(); + salesRepository.save(sales); } - - User user = optionalUser.get(); - validateAndReserve(showingId, user, ticketDtoList); }); return "success"; } - public void validateAndReserve(Long showingId, User user, List ticketDtoList) { + public void validateReservation(Long showingId, String username, List ticketDtoList) { + Optional optionalUser = userRepository.findByUsername(username); + if (optionalUser.isEmpty()) { + throw new UserNotFoundException(); + } + User user = optionalUser.get(); + List ticketList = ticketRepository.findAllByTicketIdIn( ticketDtoList.stream().map(TicketDTO::getTicketId).toList()); @@ -148,15 +168,7 @@ public void validateAndReserve(Long showingId, User user, List ticket int userAge = Period.between(user.getBirth(), LocalDate.now()).getYears(); if (userAge < movie.getRating().getAge()) throw new InvalidAgeForMovieException(); - - // 최종연산 [ 결제 완료 처리 ] - for (Ticket ticket : ticketList) { - ticket.setTicketStatus(TicketStatus.RESERVED); - Sales sales = Sales.builder().price(9000) - .user(user).ticket(ticket) - .createBy("system").build(); - salesRepository.save(sales); - } } + }