diff --git a/build.gradle b/build.gradle index 1ef8f0a..a52fe85 100644 --- a/build.gradle +++ b/build.gradle @@ -59,6 +59,14 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + + /* kafka */ + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.kafka:spring-kafka' + + testImplementation 'org.mockito:mockito-core:4.0.0' + + implementation 'org.springframework.boot:spring-boot-starter-aop' } tasks.named('test') { diff --git a/src/main/java/com/kboticket/KboticketApplication.java b/src/main/java/com/kboticket/KboticketApplication.java index bccb7f5..6c72bee 100644 --- a/src/main/java/com/kboticket/KboticketApplication.java +++ b/src/main/java/com/kboticket/KboticketApplication.java @@ -2,10 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.EnableAspectJAutoProxy; import org.springframework.scheduling.annotation.EnableScheduling; @EnableScheduling @SpringBootApplication +@EnableAspectJAutoProxy(proxyTargetClass=true) public class KboticketApplication { public static void main(String[] args) { diff --git a/src/main/java/com/kboticket/common/CustomSpringELParser.java b/src/main/java/com/kboticket/common/CustomSpringELParser.java new file mode 100644 index 0000000..7062ddd --- /dev/null +++ b/src/main/java/com/kboticket/common/CustomSpringELParser.java @@ -0,0 +1,24 @@ +package com.kboticket.common; + +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +@Slf4j +@NoArgsConstructor +public class CustomSpringELParser { + + public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) { + ExpressionParser parser = new SpelExpressionParser(); + StandardEvaluationContext context = new StandardEvaluationContext(); + + for (int i = 0; i < parameterNames.length; i++) { + context.setVariable(parameterNames[i], args[i]); + } + + return parser.parseExpression(key).getValue(context, Object.class); + } + +} diff --git a/src/main/java/com/kboticket/common/constants/KboConstant.java b/src/main/java/com/kboticket/common/constants/KboConstant.java index 60a6245..b66243d 100644 --- a/src/main/java/com/kboticket/common/constants/KboConstant.java +++ b/src/main/java/com/kboticket/common/constants/KboConstant.java @@ -11,8 +11,8 @@ public class KboConstant { public static final String BASIC_DLIIMITER = ":"; public static final String AUTH_HEADER_PREFIX = "Basic "; - public static final long CONNECT_TIMEOUT = 1 * 1000; + public static final long CONNECT_TIMEOUT = 3 * 1000; public static final long READ_TIMEOUT = 60 * 1000; - public static final long WAIT_TIME = 3 * 1000; - public static final long EXPIRED_TIME = 8 * 60 * 1000; + public static final long WAIT_TIME = 3L; + public static final long LEASE_TIME = 480L; } diff --git a/src/main/java/com/kboticket/config/KafkaConsumerConfig.java b/src/main/java/com/kboticket/config/KafkaConsumerConfig.java new file mode 100644 index 0000000..be951ae --- /dev/null +++ b/src/main/java/com/kboticket/config/KafkaConsumerConfig.java @@ -0,0 +1,39 @@ +package com.kboticket.config; + +import java.util.HashMap; +import java.util.Map; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; + +@Configuration +@EnableKafka +public class KafkaConsumerConfig { + + @Bean + public ConsumerFactory consumerFactory() { + Map config = new HashMap<>(); + config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); + config.put(ConsumerConfig.GROUP_ID_CONFIG, "ticketing-group"); + config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + + + return new DefaultKafkaConsumerFactory<>(config); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + + return factory; + } +} diff --git a/src/main/java/com/kboticket/config/KafkaProducerConfig.java b/src/main/java/com/kboticket/config/KafkaProducerConfig.java new file mode 100644 index 0000000..4c08f28 --- /dev/null +++ b/src/main/java/com/kboticket/config/KafkaProducerConfig.java @@ -0,0 +1,30 @@ +package com.kboticket.config; + +import java.util.HashMap; +import java.util.Map; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; + +@Configuration +public class KafkaProducerConfig { + + @Bean + public ProducerFactory producerFactory() { + Map config = new HashMap<>(); + config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); + config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + + return new DefaultKafkaProducerFactory<>(config); + } + + @Bean + public KafkaTemplate kafkaTemplate() { + return new KafkaTemplate<>(producerFactory()); + } +} diff --git a/src/main/java/com/kboticket/config/RedissonConfig.java b/src/main/java/com/kboticket/config/RedissonConfig.java new file mode 100644 index 0000000..a83fd42 --- /dev/null +++ b/src/main/java/com/kboticket/config/RedissonConfig.java @@ -0,0 +1,29 @@ +package com.kboticket.config; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RedissonConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + private static final String REDISSON_HOST_PREFIX = "redis://"; + + @Bean + public RedissonClient redissonClient() { + RedissonClient redissonClient = null; + Config config = new Config(); + config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + host + ":" + port); + redissonClient = Redisson.create(config); + return redissonClient; + } +} diff --git a/src/main/java/com/kboticket/config/WebSecurityConfig.java b/src/main/java/com/kboticket/config/WebSecurityConfig.java index ccdd493..812a219 100644 --- a/src/main/java/com/kboticket/config/WebSecurityConfig.java +++ b/src/main/java/com/kboticket/config/WebSecurityConfig.java @@ -45,8 +45,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/terms/**", "/games/**", "/game/**", - "/seat/**", "/payment-page", - "/payment/**" + "/seat/**", "/payment-page","/favicon.ico", + "/payment/**", "/ticket-page/**" ).permitAll() .anyRequest().authenticated()) .logout(logout -> logout diff --git a/src/main/java/com/kboticket/config/aop/AopForTransaction.java b/src/main/java/com/kboticket/config/aop/AopForTransaction.java new file mode 100644 index 0000000..ee89357 --- /dev/null +++ b/src/main/java/com/kboticket/config/aop/AopForTransaction.java @@ -0,0 +1,15 @@ +package com.kboticket.config.aop; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class AopForTransaction { + + @Transactional (propagation = Propagation.REQUIRES_NEW) + public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable { + return joinPoint.proceed(); + } +} diff --git a/src/main/java/com/kboticket/config/kafka/Consumer/KafkaConsumer.java b/src/main/java/com/kboticket/config/kafka/Consumer/KafkaConsumer.java new file mode 100644 index 0000000..70723d9 --- /dev/null +++ b/src/main/java/com/kboticket/config/kafka/Consumer/KafkaConsumer.java @@ -0,0 +1,32 @@ +package com.kboticket.config.kafka.Consumer; + +import com.kboticket.controller.QueueService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class KafkaConsumer { + + private final QueueService queueService; + + @KafkaListener(topics="ticketing-queue", groupId="ticketing_group") + public void receive(ConsumerRecord record) { + if (record == null) { + log.info("대기열이 비어 있습니다."); + } + + long offset = record.offset(); + String email = record.value(); + + processUserInQueue(offset, email); + } + + private void processUserInQueue(long offset, String email) { + queueService.addToRedisQueue(email, offset); + } +} diff --git a/src/main/java/com/kboticket/config/kafka/hadler/KafkaErrorHandler.java b/src/main/java/com/kboticket/config/kafka/hadler/KafkaErrorHandler.java new file mode 100644 index 0000000..4ad350d --- /dev/null +++ b/src/main/java/com/kboticket/config/kafka/hadler/KafkaErrorHandler.java @@ -0,0 +1,15 @@ +package com.kboticket.config.kafka.hadler; + +import org.apache.kafka.clients.consumer.Consumer; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.listener.CommonErrorHandler; +import org.springframework.stereotype.Component; + +@Component +public class KafkaErrorHandler implements CommonErrorHandler { + + public void handle(Exception thrownException, ConsumerRecord record, Consumer consumer) { + System.err.println("Error processing message: " + record.value()); + thrownException.printStackTrace(); + } +} diff --git a/src/main/java/com/kboticket/config/kafka/producer/KafkaProducer.java b/src/main/java/com/kboticket/config/kafka/producer/KafkaProducer.java new file mode 100644 index 0000000..649edd3 --- /dev/null +++ b/src/main/java/com/kboticket/config/kafka/producer/KafkaProducer.java @@ -0,0 +1,19 @@ +package com.kboticket.config.kafka.producer; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class KafkaProducer { + + private final KafkaTemplate kafkaTemplate; + + public void create(Long gameId, String email) { + kafkaTemplate.send("ticketing-queue", email, email); + log.info("User added to queue: {}", email); + } +} diff --git a/src/main/java/com/kboticket/config/redis/RedisConfig.java b/src/main/java/com/kboticket/config/redis/RedisConfig.java index 11bc0d1..0f6d735 100644 --- a/src/main/java/com/kboticket/config/redis/RedisConfig.java +++ b/src/main/java/com/kboticket/config/redis/RedisConfig.java @@ -36,7 +36,7 @@ public RedisTemplate redisTemplate() { } @Bean - public RedissonClient redissonClient() { + public RedissonClient redissonClient_redis() { RedissonClient redissonClient = null; Config config = new Config(); config.useSingleServer().setAddress("redis://" + redisProperties.getHost() + ":" + redisProperties.getPort()); diff --git a/src/main/java/com/kboticket/config/redisson/DistributedLock.java b/src/main/java/com/kboticket/config/redisson/DistributedLock.java new file mode 100644 index 0000000..c704b20 --- /dev/null +++ b/src/main/java/com/kboticket/config/redisson/DistributedLock.java @@ -0,0 +1,21 @@ +package com.kboticket.config.redisson; + +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(); + + TimeUnit timeUnit() default TimeUnit.SECONDS; + + long waitTime() default 3L; + + long leaseTime() default 480L; +} + diff --git a/src/main/java/com/kboticket/config/redisson/DistributedLockAop.java b/src/main/java/com/kboticket/config/redisson/DistributedLockAop.java new file mode 100644 index 0000000..b8cda38 --- /dev/null +++ b/src/main/java/com/kboticket/config/redisson/DistributedLockAop.java @@ -0,0 +1,56 @@ +package com.kboticket.config.redisson; + +import com.kboticket.common.CustomSpringELParser; +import com.kboticket.config.aop.AopForTransaction; +import com.kboticket.enums.ErrorCode; +import com.kboticket.exception.KboTicketException; +import java.lang.reflect.Method; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class DistributedLockAop { + + private final RedissonClient redissonClient; + private final AopForTransaction aopForTransaction; + + @Around("@annotation(DistributedLock)") + public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + DistributedLock distributedLock = method.getAnnotation(DistributedLock.class); + + String lockKey = (String) CustomSpringELParser + .getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key()); + + RLock rLock = redissonClient.getLock(lockKey); + + if (rLock.isLocked()) { + throw new KboTicketException(ErrorCode.ALREADY_SELECTED_SEATS); + } + + try { + boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit()); + if (!available) { + throw new KboTicketException(ErrorCode.FAILED_TRY_ROCK); + } + + return aopForTransaction.proceed(joinPoint); + } catch (KboTicketException e) { + if (rLock.isHeldByCurrentThread()) { + rLock.unlock(); + } + throw new KboTicketException(ErrorCode.FAILED_DURING_TRANSACTION); + } + } +} diff --git a/src/main/java/com/kboticket/controller/QueueService.java b/src/main/java/com/kboticket/controller/QueueService.java new file mode 100644 index 0000000..5c966b4 --- /dev/null +++ b/src/main/java/com/kboticket/controller/QueueService.java @@ -0,0 +1,102 @@ +package com.kboticket.controller; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RedissonClient; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Slf4j +@Setter @Getter +@Service +@RequiredArgsConstructor +public class QueueService { + + private final RedissonClient redissonClient = null; + private final RedisTemplate redisTemplate; + + private final Map sseEmitters = new ConcurrentHashMap<>(); + + private static final long FIRST_INDEX = 0; + private static final long LAST_INDEX = -1; + private static final long ENTRY_SIZE = 10; + private static final String QUEUE_ID = "ticketing-queue"; + + /** + * 작업 큐에 추가 + */ + public void addToRedisQueue(String userId, Long offset) { + long now = System.currentTimeMillis(); + redisTemplate.opsForZSet().add("ticketing-queue", userId, offset); + log.info("대기열에 추가되었습니다. {} /{}초", userId, now); + } + + /** + * 대기 순번 조회 + */ + public String getQueuePosition() { + Set queue = redisTemplate.opsForZSet().range(QUEUE_ID, FIRST_INDEX, LAST_INDEX); + for (Object user : queue) { + if (user.toString().equals("test0")) { + Long rank = redisTemplate.opsForZSet().rank(QUEUE_ID, user); + log.info("'{}'님의 현재 순번은 {}번 입니다.", user, rank); + return rank.toString(); + } + } + return ""; + } + + @Scheduled(fixedDelay = 1000) + public void sendEvents(){ + enterPageFromQueue(); + getQueuePosition(); + } + + /** + * 화면에 진입 + */ + public void enterPageFromQueue(){ + final long start = FIRST_INDEX; + final long end = ENTRY_SIZE - 1; + + Set queue = redisTemplate.opsForZSet().range(QUEUE_ID, start, end); + for (Object user : queue) { + log.info(user + " 님이 곧 예매 화면으로 진입합니다."); + redisTemplate.opsForZSet().remove(QUEUE_ID, user); + sendEntryNotificatiton(user.toString()); + } + } + + public SseEmitter createEmitter(String email) { + // 연결 추가 + SseEmitter emitter = new SseEmitter(60*60*1000L); + // Timeout이 발생하거나 완료되면 연결 목록에서 제거 + sseEmitters.put(email, emitter); + emitter.onCompletion(() -> sseEmitters.remove(email)); + emitter.onTimeout(() -> sseEmitters.remove(email)); + return emitter; + } + + /** + * SSE를 통해 유저에게 알림 전송 + */ + private void sendEntryNotificatiton(String email) { + SseEmitter emitter = sseEmitters.get(email); + if (emitter == null) return; + try { + emitter.send(SseEmitter.event().name("yourTurn").data(true)); + emitter.complete(); + sseEmitters.remove(email); + } catch (Exception e) { + log.info("[SSE Exception] ====> {}" + e.getMessage()); + emitter.completeWithError(e); + } + } +} diff --git a/src/main/java/com/kboticket/controller/game/GameController.java b/src/main/java/com/kboticket/controller/game/GameController.java index 7486a03..998e2a0 100644 --- a/src/main/java/com/kboticket/controller/game/GameController.java +++ b/src/main/java/com/kboticket/controller/game/GameController.java @@ -1,13 +1,20 @@ package com.kboticket.controller.game; import com.kboticket.common.CommonResponse; -import com.kboticket.controller.game.dto.GameSearchResponse; -import com.kboticket.controller.game.dto.GameSearchRequest; +import com.kboticket.controller.QueueService; import com.kboticket.controller.game.dto.GameDetailResponse; +import com.kboticket.controller.game.dto.GameSearchRequest; +import com.kboticket.controller.game.dto.GameSearchResponse; import com.kboticket.service.game.GameService; import com.kboticket.service.game.dto.GameDetailDto; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @RestController @RequestMapping("/game") @@ -16,28 +23,41 @@ public class GameController { private final GameService gameService; + private final QueueService queueService; + /** * 경기 목록 조회 */ @GetMapping("/list") public CommonResponse list(@RequestBody GameSearchRequest gameSearchRequest, - @RequestParam(value = "cursor", required = false) String cursorId, - @RequestParam(value = "limit", defaultValue = "10") int limit) { + @RequestParam(value = "cursor", required = false) String cursorId, + @RequestParam(value = "limit", defaultValue = "10") int limit) { GameSearchResponse gameList = gameService.getGameList(gameSearchRequest, cursorId, limit); return new CommonResponse<>(gameList); } /** - * 경기 상세 + * 경기 예매 상세 화면 */ @GetMapping("/{gameId}") public CommonResponse view(@PathVariable Long gameId) { - GameDetailDto gameDetailDto = gameService.findById(gameId); + GameDetailDto gameDetailDto = gameService.findById(gameId); GameDetailResponse response = GameDetailResponse.from(gameDetailDto); return new CommonResponse<>(response); } + /** + * 실시간 순번 조회 및 경기 좌석 예매 화면 진입 + */ + @GetMapping(value = "/queue-status/{gameId}", produces = "text/event-stream") + public SseEmitter getQueueStatus(@PathVariable Long gameId, String email) { + SseEmitter sseEmitter = queueService.createEmitter(email); + + queueService.sendEvents(); + + return sseEmitter; + } } diff --git a/src/main/java/com/kboticket/controller/game/GamePageController.java b/src/main/java/com/kboticket/controller/game/GamePageController.java new file mode 100644 index 0000000..b9d24f2 --- /dev/null +++ b/src/main/java/com/kboticket/controller/game/GamePageController.java @@ -0,0 +1,21 @@ +package com.kboticket.controller.game; + +import com.kboticket.config.kafka.producer.KafkaProducer; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +@Controller +@RequiredArgsConstructor +public class GamePageController { + + private final KafkaProducer producer; + + @GetMapping("/ticket-page/{gameId}") + public String enterQueuePage(@PathVariable Long gameId, String email) { + producer.create(gameId, email); + + return "ticket-queue"; + } +} diff --git a/src/main/java/com/kboticket/controller/payment/PaymentPageController.java b/src/main/java/com/kboticket/controller/payment/PaymentPageController.java index 2ca86a4..aa43530 100644 --- a/src/main/java/com/kboticket/controller/payment/PaymentPageController.java +++ b/src/main/java/com/kboticket/controller/payment/PaymentPageController.java @@ -1,10 +1,12 @@ package com.kboticket.controller.payment; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @Controller +@RequiredArgsConstructor public class PaymentPageController { @GetMapping("/payment-page") diff --git a/src/main/java/com/kboticket/controller/reservation/ReservationController.java b/src/main/java/com/kboticket/controller/reservation/ReservationController.java index beef633..5df9ead 100644 --- a/src/main/java/com/kboticket/controller/reservation/ReservationController.java +++ b/src/main/java/com/kboticket/controller/reservation/ReservationController.java @@ -1,7 +1,7 @@ package com.kboticket.controller.reservation; import com.kboticket.dto.ReservationDto; -import com.kboticket.service.ReservationService; +import com.kboticket.service.reserve.ReservationService; import com.kboticket.service.seat.SeatService; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -24,15 +24,14 @@ public class ReservationController { @ResponseStatus(HttpStatus.OK) public void reservations(Authentication authentication, @RequestBody ReservationDto reservationDto, - @RequestParam Long gameId) { + @RequestParam Long gameId) throws InterruptedException { String email = authentication.getName(); Set seatIds = reservationDto.getSeatIds().stream() - .map(seatService::getSeatDto) - .map(seat -> seat.getId()) - .collect(Collectors.toSet()); + .collect(Collectors.toSet()); + + reservationService.selectSeat(seatIds, gameId, email); - reservationService.reserve(seatIds, gameId, email); } } diff --git a/src/main/java/com/kboticket/enums/ErrorCode.java b/src/main/java/com/kboticket/enums/ErrorCode.java index 207650a..7c45d04 100644 --- a/src/main/java/com/kboticket/enums/ErrorCode.java +++ b/src/main/java/com/kboticket/enums/ErrorCode.java @@ -71,9 +71,10 @@ public enum ErrorCode { EXCEED_SEATS_LIMIT(30025, "The maximum number of seats : 4", HttpStatus.BAD_REQUEST), FAILED_RESERVATION(30026, "reservation be failed", HttpStatus.BAD_REQUEST), INVALID_START_DATE(30027, "Start date cannot be before the current date", HttpStatus.BAD_REQUEST), - EXIST_SELECTED_SEATS(30028, "The selected seat(s) already exist(s)", HttpStatus.BAD_REQUEST), + EXIST_SELECTED_SEATS(30028, "You already have a selected seat.", HttpStatus.BAD_REQUEST), NOT_FOUND_RESERVATION(30029, "Reservation could not be found", HttpStatus.NOT_FOUND), FAILED_TRY_ROCK(30030, "Failed to acquire lock", HttpStatus.CONFLICT), + ALREADY_SELECTED_SEATS(30031, "This seat has already been selected.", HttpStatus.CONFLICT), PAYMENT_TIMEOUT_EXCEPTION(30031, "An tiemout error occurred during payment. ", HttpStatus.CONFLICT), @@ -81,7 +82,8 @@ public enum ErrorCode { PAYMENT_CANCEL_EXCEPTION(30033, "An error occurred during cancel payment.", HttpStatus.CONFLICT), PAYMENT_NOT_FOUND(30034, "payment could not be found", HttpStatus.NOT_FOUND), PAYMENT_AMOUNT_EXP(30035, "invalid payment amount", HttpStatus.CONFLICT), - ALREADY_APPROVED(30036, "already approved", HttpStatus.CONFLICT) + ALREADY_APPROVED(30036, "already approved", HttpStatus.CONFLICT), + FAILED_DURING_TRANSACTION(30037, "fail to lock during transaction", HttpStatus.CONFLICT) ; public final int code; public final String message; diff --git a/src/main/java/com/kboticket/scheduler/TikcketOpeningsScheduler.java b/src/main/java/com/kboticket/scheduler/TikcketOpeningsScheduler.java index 6de6332..f007d54 100644 --- a/src/main/java/com/kboticket/scheduler/TikcketOpeningsScheduler.java +++ b/src/main/java/com/kboticket/scheduler/TikcketOpeningsScheduler.java @@ -1,3 +1,4 @@ + package com.kboticket.scheduler; import com.kboticket.service.game.GameService; @@ -13,7 +14,7 @@ public class TikcketOpeningsScheduler { private final GameService gameService; - @Scheduled(cron = "0 0 11 * * ?") + @Scheduled(cron = "0 48 13 * * ?") public void ticketOpen() { gameService.openTicketing(); } diff --git a/src/main/java/com/kboticket/service/ReservationService.java b/src/main/java/com/kboticket/service/reserve/ReservationService.java similarity index 55% rename from src/main/java/com/kboticket/service/ReservationService.java rename to src/main/java/com/kboticket/service/reserve/ReservationService.java index a92ea44..592f5e1 100644 --- a/src/main/java/com/kboticket/service/ReservationService.java +++ b/src/main/java/com/kboticket/service/reserve/ReservationService.java @@ -1,68 +1,48 @@ -package com.kboticket.service; +package com.kboticket.service.reserve; -import com.kboticket.common.constants.KboConstant; import com.kboticket.dto.ReservedSeatInfo; import com.kboticket.enums.ErrorCode; -import com.kboticket.enums.ReservationStatus; import com.kboticket.exception.KboTicketException; +import java.util.List; +import java.util.Map; +import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.redisson.api.RBucket; import org.redisson.api.RKeys; import org.redisson.api.RLock; +import org.redisson.api.RMap; import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.TimeUnit; - @Slf4j @Service -@Transactional +@Transactional(readOnly = true) @RequiredArgsConstructor public class ReservationService { + @Autowired private final RedissonClient redissonClient; - public void reserve(Set seatIds, Long gameId, String email) { - // 유저가 해당 경기에 이미 선한 좌석이 존재하는 경우 - isExistSeatByUser(gameId, email); - // 좌석 수 알맞은지 확인 - isValidateSeatsCount(seatIds); - - List locks = new ArrayList<>(); - for (Long seatId : seatIds) { - String seatKey = KboConstant.SEAT_LOCK + gameId + seatId; - RLock rLock = redissonClient.getLock(seatKey); - // 좌석이 선점되어있는지 확인 - isSeatHold(rLock); + @Autowired + private final ReserveInternalService internalService; - try { - boolean acquired = rLock.tryLock(KboConstant.WAIT_TIME, KboConstant.EXPIRED_TIME, TimeUnit.SECONDS); - if (!acquired) { - throw new KboTicketException(ErrorCode.FAILED_TRY_ROCK); - } - locks.add(rLock); - holdSeat(seatKey, gameId, seatId, email); + public void selectSeat(Set seatIds, Long gameId, String email) throws InterruptedException { + checkIfUserHasSelectedSeats(gameId, email); - } catch (KboTicketException e) { - releaseLocks(locks); - throw new KboTicketException(ErrorCode.FAILED_TRY_ROCK, null, log::error); + isValidateSeatsCount(seatIds); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - releaseLocks(locks); - } + for (Long seatId : seatIds) { + String seatKey = "TICKET_" + gameId + seatId; + internalService.lockSeat(seatKey, email); } } - private void isExistSeatByUser(Long gameId, String email) { + + private void checkIfUserHasSelectedSeats(Long gameId, String email) { RKeys keys = redissonClient.getKeys(); Iterable keysIterable = keys.getKeysByPattern("seatLock:" + gameId + "*"); @@ -83,19 +63,16 @@ private void isSeatHold(RLock rLock) { } } - private void holdSeat(String seatKey, Long gameId, Long seatId, String email) { - // 게임아이디는 별도로 락과 섞이지 않게, 하나의 역할만 - RBucket seatBucket = redissonClient.getBucket(seatKey); - ReservedSeatInfo seatBucketValue = ReservedSeatInfo.builder() - .gameId(gameId) - .seatId(seatId) - .email(email) - .status(ReservationStatus.HOLD) - .reservedDate(LocalDateTime.now()) - .build(); + public void holdSeat(String seatKey, Long gameId, Long seatId, String email) { + log.info("holdseat"); - seatBucket.set(seatBucketValue, KboConstant.EXPIRED_TIME, TimeUnit.MILLISECONDS); + RMap lockMap = redissonClient.getMap(seatKey); + lockMap.put("email", email); + } + public String getSeat(String seatKey) { + RMap lockMap = redissonClient.getMap(seatKey); + return lockMap.get(seatKey); } // 좌석 수 valid (0 < cnt <= 4) @@ -105,20 +82,32 @@ private void isValidateSeatsCount(Set seatIds) { // KboTicketException 어디에서 발생했ㅈ는디? 특정 파라미터로 발생하ㅏㄹ 수 있는 예외일때 seatid도 같이 보내줌, // 로그가 어느 클래스에서 발생? 지금은 다 global 에서 찍힘 - // Map.of("seatId", seatIds), log::info -> 에러 추적이 편함, 컨슈머를 이용해서 찍음 globalexceptiㅐㅜ 수정해야함 + // Map.of("seatId", seatIds), log::info -> 에러 추적이 편함, 컨슈머를 이용해서 찍음 globalexceptiㅐ 수정해야함 // log::info - 유저가 없는 경우 -> 로그인 하는 경우 에러, 로직에서 에러로 처리할지, 이 클래스에서 발생한 경우 빨리 처리해야한다. -> log::error // 추적이 필요하지 않으면 에러코드만 넘겨도된다. - throw new KboTicketException(ErrorCode.EMPTY_SEATS_EXCEPTION, Map.of("seatId", seatIds), log::info); + throw new KboTicketException(ErrorCode.EMPTY_SEATS_EXCEPTION); } else if (seatCnt > 4) { - throw new KboTicketException(ErrorCode.EXCEED_SEATS_LIMIT); + throw new KboTicketException(ErrorCode.EXCEED_SEATS_LIMIT, Map.of("seatsCount", seatIds.size()), log::info); } } private void releaseLocks(List locks) { + if (locks == null || locks.isEmpty()) return ; for (RLock rLock : locks) { - // try catch 걸아야함, 에러가 나면 무시,로그찍기 - rLock.unlock(); + try { + if (rLock.isHeldByCurrentThread()) { + rLock.unlock(); + } + } catch (KboTicketException e) { + // 예외가 발생하면 무시하고 로그 기록 + log.error("Failed to release lock for seat: " + rLock.getName(), e); + + // throw new KboTicketException(ErrorCode.EMPTY_SEATS_EXCEPTION, Map.of("locks", rLock), log::error); + } } } + + + } diff --git a/src/main/java/com/kboticket/service/reserve/ReserveInternalService.java b/src/main/java/com/kboticket/service/reserve/ReserveInternalService.java new file mode 100644 index 0000000..5cd60d2 --- /dev/null +++ b/src/main/java/com/kboticket/service/reserve/ReserveInternalService.java @@ -0,0 +1,29 @@ +package com.kboticket.service.reserve; + +import com.kboticket.config.redisson.DistributedLock; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RMap; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ReserveInternalService { + + @Autowired + private final RedissonClient redissonClient; + + @DistributedLock(key = "#lockName") + public void lockSeat(String lockName, String email) { + holdSeat(lockName, email); + } + + public void holdSeat(String seatKey, String email) { + RMap lockMap = redissonClient.getMap(seatKey); + lockMap.put("email", email); + } + +} diff --git a/src/test/java/com/kboticket/controller/game/GameControllerTest.java b/src/test/java/com/kboticket/controller/game/GameControllerTest.java index acb1510..2f2eff5 100644 --- a/src/test/java/com/kboticket/controller/game/GameControllerTest.java +++ b/src/test/java/com/kboticket/controller/game/GameControllerTest.java @@ -1,5 +1,18 @@ package com.kboticket.controller.game; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.kboticket.controller.QueueService; import com.kboticket.controller.game.dto.GameSearchRequest; import com.kboticket.controller.game.dto.GameSearchResponse; import com.kboticket.service.game.GameService; @@ -14,15 +27,6 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.springframework.http.MediaType.APPLICATION_JSON; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @WebMvcTest(GameController.class) @ExtendWith(SpringExtension.class) public class GameControllerTest { @@ -32,9 +36,13 @@ public class GameControllerTest { @MockBean private GameService gameService; + @MockBean + private QueueService queueService; + + @BeforeEach void setUp() { - GameController gameController = new GameController(gameService); + GameController gameController = new GameController(gameService, queueService); this.mockMvc = MockMvcBuilders.standaloneSetup(gameController).build(); } diff --git a/src/test/java/com/kboticket/controller/game/GameKafkaControllerTest.java b/src/test/java/com/kboticket/controller/game/GameKafkaControllerTest.java new file mode 100644 index 0000000..f2472bd --- /dev/null +++ b/src/test/java/com/kboticket/controller/game/GameKafkaControllerTest.java @@ -0,0 +1,86 @@ +package com.kboticket.controller.game; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.kboticket.config.kafka.producer.KafkaProducer; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import lombok.extern.slf4j.Slf4j; +import org.junit.Test; +import org.junit.jupiter.api.DisplayName; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; + +@Slf4j +@SpringBootTest +@RunWith(SpringRunner.class) +public class GameKafkaControllerTest { + + @Autowired + private GameController gameController; + + @Autowired + private KafkaProducer producer; + + private static final Long gameId = 123L; + + @Test + @DisplayName("Kafka 대기열 테스트") + public void testKafkaQueue() throws InterruptedException { + int threadNum = 100; + CountDownLatch latch = new CountDownLatch(threadNum); + + ExecutorService executerService = Executors.newFixedThreadPool(threadNum); + + for (int i = 0; i < threadNum; i++) { + String email = "test" + i; + executerService.submit(() -> { + try { + producer.create(gameId, email); + } catch (Exception e) { + log.info("[Exception] 사용자 {} ====> {}", email, e.getMessage()); + } finally { + latch.countDown(); + } + }); + } + + Thread.sleep(100000); + latch.await(); + executerService.shutdown(); + + log.info("Remaining latch count: {}", latch.getCount()); + assertEquals(0, latch.getCount(), "All requests should be processed"); + } + + @Test + @DisplayName("순번 조회 및 화면 진입 테스트") + public void testGetQueueStatus() throws InterruptedException { + int threadNum = 10; + CountDownLatch latch = new CountDownLatch(threadNum); + + ExecutorService executerService = Executors.newFixedThreadPool(threadNum); + + for (int i = 0; i < threadNum; i++) { + String email = "test" + i; + executerService.submit(() -> { + try { + gameController.getQueueStatus(gameId, email); + } catch (Exception e) { + log.info("[Exception] ====> {}", e.getMessage()); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + Thread.sleep(100000); + executerService.shutdown(); + + log.info("Remaining latch count: {}", latch.getCount()); + assertEquals(0, latch.getCount(), "All requests should be processed"); + } +} \ No newline at end of file diff --git a/src/test/java/com/kboticket/controller/reservation/ReservationControllerTest.java b/src/test/java/com/kboticket/controller/reservation/ReservationControllerTest.java index 03ccdf7..76a9467 100644 --- a/src/test/java/com/kboticket/controller/reservation/ReservationControllerTest.java +++ b/src/test/java/com/kboticket/controller/reservation/ReservationControllerTest.java @@ -1,10 +1,9 @@ package com.kboticket.controller.reservation; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.kboticket.dto.ReservationDto; import com.kboticket.enums.SeatLevel; -import com.kboticket.service.ReservationService; +import com.kboticket.service.reserve.ReservationService; import com.kboticket.service.seat.SeatService; import com.kboticket.service.seat.dto.SeatDto; import org.junit.jupiter.api.BeforeEach; @@ -91,7 +90,7 @@ void reserve() throws Exception { .andExpect(status().isOk()); verify(reservationService, times(1)) - .reserve(Set.of(1L, 2L), 1L, "user@example.com"); + .selectSeat(Set.of(1L, 2L), 1L, "user@example.com"); } } \ No newline at end of file diff --git a/src/test/java/com/kboticket/service/ReservationServiceTest.java b/src/test/java/com/kboticket/service/ReservationServiceTest.java deleted file mode 100644 index aa148fc..0000000 --- a/src/test/java/com/kboticket/service/ReservationServiceTest.java +++ /dev/null @@ -1,106 +0,0 @@ -//package com.kboticket.service; -// -//import com.kboticket.enums.ErrorCode; -//import com.kboticket.exception.KboTicketException; -//import org.junit.Test; -//import org.junit.jupiter.api.BeforeEach; -//import org.junit.runner.RunWith; -//import org.mockito.InjectMocks; -//import org.mockito.Mock; -//import org.redisson.api.RLock; -//import org.redisson.api.RedissonClient; -//import org.springframework.boot.test.context.SpringBootTest; -//import org.springframework.test.context.junit4.SpringRunner; -//import org.springframework.transaction.annotation.Transactional; -// -//import java.util.Arrays; -//import java.util.HashSet; -//import java.util.Set; -//import java.util.concurrent.TimeUnit; -// -//import static org.junit.jupiter.api.Assertions.*; -//import static org.mockito.ArgumentMatchers.*; -//import static org.mockito.Mockito.*; -// -//@RunWith(SpringRunner.class) -//@SpringBootTest -//@Transactional -//public class ReservationServiceTest { -// -// @InjectMocks -// private ReservationService reservationService; -// -// @Mock -// private RedissonClient redissonClient; -// -// @Mock -// private RLock rLock; -// -// -// @BeforeEach -// void setUp() { -// when(redissonClient.getLock(anyString())).thenReturn(rLock); -// } -// -// @Test -// void shouldReserveSeatsSuccessfully() throws InterruptedException { -// Set seatIds = new HashSet<>(Arrays.asList(1L, 2L, 3L)); -// Long gameId = 1L; -// String email = "test@example.com"; -// -// when(rLock.tryLock(anyLong(), anyLong(), any(TimeUnit.class))).thenReturn(true); -// -// reservationService.reserve(seatIds, gameId, email); -// -// verify(rLock, times(seatIds.size())).tryLock(anyLong(), anyLong(), any(TimeUnit.class)); -// verify(rLock, times(0)).unlock(); -// } -// -// @Test -// void shouldThrowExceptionWhenSeatIsAlreadyHeld() throws Exception { -// Set seatIds = new HashSet<>(Arrays.asList(1L, 2L)); -// Long gameId = 1L; -// String email = "test@example.com"; -// -// when(rLock.tryLock(anyLong(), anyLong(), any(TimeUnit.class))).thenReturn(false); -// -// KboTicketException exception = assertThrows(KboTicketException.class, () -> { -// reservationService.reserve(seatIds, gameId, email); -// }); -// -// assertEquals("테스트가 실패하면 메시지 표", ErrorCode.FAILED_TRY_ROCK.message, exception.getMessage()); -// verify(rLock, times(1)).unlock(); -// } -// -// @Test -// void shouldReleaseLocksOnException() throws InterruptedException { -// Set seatIds = new HashSet<>(Arrays.asList(1L, 2L)); -// Long gameId = 1L; -// String email = "test@example.com"; -// -// when(rLock.tryLock(anyLong(), anyLong(), any(TimeUnit.class))) -// .thenReturn(true) -// .thenThrow(new KboTicketException(ErrorCode.FAILED_TRY_ROCK)); -// -// try { -// reservationService.reserve(seatIds, gameId, email); -// } catch (KboTicketException e) { -// // Exception is expected -// } -// -// verify(rLock, times(1)).unlock(); -// } -// -// @Test -// void shouldHandleInterruptedException() throws InterruptedException { -// Set seatIds = new HashSet<>(Arrays.asList(1L, 2L)); -// Long gameId = 1L; -// String email = "test@example.com"; -// -// when(rLock.tryLock(anyLong(), anyLong(), any(TimeUnit.class))).thenThrow(new InterruptedException()); -// -// assertDoesNotThrow(() -> reservationService.reserve(seatIds, gameId, email)); -// -// verify(rLock, times(1)).unlock(); -// } -//} \ No newline at end of file diff --git a/src/test/java/com/kboticket/service/reservation/ReservationServiceTest.java b/src/test/java/com/kboticket/service/reservation/ReservationServiceTest.java new file mode 100644 index 0000000..3e767c4 --- /dev/null +++ b/src/test/java/com/kboticket/service/reservation/ReservationServiceTest.java @@ -0,0 +1,113 @@ +package com.kboticket.service.reservation; + + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.kboticket.exception.KboTicketException; +import com.kboticket.service.reserve.ReservationService; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.extern.slf4j.Slf4j; +import org.junit.Test; +import org.junit.jupiter.api.DisplayName; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; + +@Slf4j +@SpringBootTest +@RunWith(SpringRunner.class) +public class ReservationServiceTest { + + @Autowired + private ReservationService reservationService; + + private static final Long GAME_ID = 123L; + private static final Set SEATS = Set.of(345L, 346L, 789L, 234L); + private static final Set SEAT = Set.of(34L); + + @Test + @DisplayName("단순 호출 테스트") + public void testCallMethod() throws InterruptedException { + reservationService.selectSeat(SEAT, GAME_ID, "user@example.com"); + } + + + @Test + @DisplayName("100명 의 사용자가 동일한 하나의 좌석 선택 - 동시성 테스트") + public void testMultiReserve() throws InterruptedException { + int threadNum = 1000; + CountDownLatch latch = new CountDownLatch(threadNum); + AtomicInteger successCounter = new AtomicInteger(0); + AtomicInteger failureCounter = new AtomicInteger(0); + + ExecutorService executorService = Executors.newFixedThreadPool(threadNum); + + for (int i = 0; i < threadNum; i++) { + String email = i + "@naver.com"; + executorService.submit(() -> { + try { + reservationService.selectSeat(SEAT, GAME_ID, email); + successCounter.incrementAndGet(); + } catch (Exception e) { + + + failureCounter.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + log.info("좌석예약에 실패한 사용자 수 ======> {}", failureCounter.get()); + assertEquals(1, successCounter.get(), "하나의 사용자만 좌석 예약에 성공해야 합니다."); + assertEquals(threadNum - 1, failureCounter.get(), "한 명을 제외한 사용자는 좌석 예약에 실패합니다."); + } + + + @Test + @DisplayName("두 좌석에 대해 여러 사용자가 선점 시도 - 동시성 테스트") + public void testMultiReserveOnTwoSeats() throws InterruptedException { + int threadNum = 1000; + CountDownLatch latch = new CountDownLatch(threadNum); + AtomicInteger successCounter = new AtomicInteger(0); + AtomicInteger failureCounter = new AtomicInteger(0); + + ExecutorService executorService = Executors.newFixedThreadPool(threadNum); + + for (int i = 0; i < threadNum; i++) { + String email = i + "@example.com"; + executorService.submit(() -> { + try { + reservationService.selectSeat(SEATS, GAME_ID, email); + successCounter.incrementAndGet(); + + } catch (KboTicketException e) { + log.error("[kboException]사용자 {} 락 실패 : {}", email, e.getMessage()); + failureCounter.incrementAndGet(); + } catch (Exception e) { + log.error("[Exception]사용자 {} 좌석 예약 실패: {}", email, e.getMessage()); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + log.info("failureCounter ======> {}", failureCounter.get()); + log.info("successCounter ======> {}", successCounter.get()); + + + assertEquals(1, successCounter.get(), "하나의 사용자만 좌석 예약에 성공해야 합니다."); + } + +}