diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..506a449f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +version: '3' +services: + rabbitmq: + image: rabbitmq:3-management + container_name: rabbitmq + ports: + - "5672:5672" + - "15672:15672" + - "15692:15692" + environment: + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/TickleApplication.java b/src/main/java/com/profect/tickle/TickleApplication.java index 7273b9c9..1d09dec7 100644 --- a/src/main/java/com/profect/tickle/TickleApplication.java +++ b/src/main/java/com/profect/tickle/TickleApplication.java @@ -1,10 +1,12 @@ package com.profect.tickle; +import org.springframework.amqp.rabbit.annotation.EnableRabbit; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.scheduling.annotation.EnableScheduling; +@EnableRabbit @SpringBootApplication @EnableScheduling @ConfigurationPropertiesScan(basePackages = "com.profect.tickle") diff --git a/src/main/java/com/profect/tickle/domain/chat/listener/RabbitMQMessageListener.java b/src/main/java/com/profect/tickle/domain/chat/listener/RabbitMQMessageListener.java index 474e4068..33f8d92d 100644 --- a/src/main/java/com/profect/tickle/domain/chat/listener/RabbitMQMessageListener.java +++ b/src/main/java/com/profect/tickle/domain/chat/listener/RabbitMQMessageListener.java @@ -1,13 +1,11 @@ package com.profect.tickle.domain.chat.listener; import com.fasterxml.jackson.databind.ObjectMapper; -import com.profect.tickle.domain.chat.dto.request.ChatMessageSendRequestDto; -import com.profect.tickle.domain.chat.entity.ChatMessageType; import com.profect.tickle.domain.chat.entity.ChatRoom; import com.profect.tickle.domain.chat.repository.ChatRoomRepository; import com.profect.tickle.domain.member.entity.Member; import com.profect.tickle.domain.member.repository.MemberRepository; -import com.profect.tickle.global.config.RabbitMQConfig; +import com.profect.tickle.global.config.RabbitMQChatConfig; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.rabbit.annotation.RabbitListener; @@ -29,7 +27,7 @@ public class RabbitMQMessageListener { /** * 채팅 메시지 큐 리스너 */ - @RabbitListener(queues = RabbitMQConfig.CHAT_MESSAGE_QUEUE) + @RabbitListener(queues = RabbitMQChatConfig.CHAT_MESSAGE_QUEUE) public void handleChatMessage(Map messageMap) { try { log.debug("RabbitMQ에서 채팅 메시지 수신: {}", messageMap); @@ -63,7 +61,7 @@ public void handleChatMessage(Map messageMap) { /** * 채팅 알림 큐 리스너 */ - @RabbitListener(queues = RabbitMQConfig.CHAT_NOTIFICATION_QUEUE) + @RabbitListener(queues = RabbitMQChatConfig.CHAT_NOTIFICATION_QUEUE) public void handleChatNotification(Map notificationMap) { try { log.debug("RabbitMQ에서 채팅 알림 수신: {}", notificationMap); @@ -87,7 +85,7 @@ public void handleChatNotification(Map notificationMap) { /** * 채팅 파일 큐 리스너 */ - @RabbitListener(queues = RabbitMQConfig.CHAT_FILE_QUEUE) + @RabbitListener(queues = RabbitMQChatConfig.CHAT_FILE_QUEUE) public void handleChatFile(Map fileMap) { try { log.debug("RabbitMQ에서 채팅 파일 수신: {}", fileMap); diff --git a/src/main/java/com/profect/tickle/domain/chat/service/RabbitMQChatService.java b/src/main/java/com/profect/tickle/domain/chat/service/RabbitMQChatService.java index 9e00c0da..4612da9a 100644 --- a/src/main/java/com/profect/tickle/domain/chat/service/RabbitMQChatService.java +++ b/src/main/java/com/profect/tickle/domain/chat/service/RabbitMQChatService.java @@ -1,7 +1,7 @@ package com.profect.tickle.domain.chat.service; import com.fasterxml.jackson.databind.ObjectMapper; -import com.profect.tickle.global.config.RabbitMQConfig; +import com.profect.tickle.global.config.RabbitMQChatConfig; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.rabbit.core.RabbitTemplate; @@ -29,8 +29,8 @@ public void sendMessage(String message) { // 채팅 큐로 메시지 전송 rabbitTemplate.convertAndSend( - RabbitMQConfig.CHAT_EXCHANGE, - RabbitMQConfig.MESSAGE_ROUTING_KEY, + RabbitMQChatConfig.CHAT_EXCHANGE, + RabbitMQChatConfig.MESSAGE_ROUTING_KEY, messageMap ); @@ -53,8 +53,8 @@ public void sendNotification(String notification) { // 알림 큐로 메시지 전송 rabbitTemplate.convertAndSend( - RabbitMQConfig.CHAT_EXCHANGE, - RabbitMQConfig.NOTIFICATION_ROUTING_KEY, + RabbitMQChatConfig.CHAT_EXCHANGE, + RabbitMQChatConfig.NOTIFICATION_ROUTING_KEY, notificationMap ); @@ -77,8 +77,8 @@ public void sendFileMessage(String fileMessage) { // 파일 큐로 메시지 전송 rabbitTemplate.convertAndSend( - RabbitMQConfig.CHAT_EXCHANGE, - RabbitMQConfig.FILE_ROUTING_KEY, + RabbitMQChatConfig.CHAT_EXCHANGE, + RabbitMQChatConfig.FILE_ROUTING_KEY, fileMap ); diff --git a/src/main/java/com/profect/tickle/domain/event/controller/EventController.java b/src/main/java/com/profect/tickle/domain/event/controller/EventController.java index f84a8a09..5b0d03e4 100644 --- a/src/main/java/com/profect/tickle/domain/event/controller/EventController.java +++ b/src/main/java/com/profect/tickle/domain/event/controller/EventController.java @@ -1,11 +1,12 @@ package com.profect.tickle.domain.event.controller; -import com.profect.tickle.domain.event.dto.response.*; import com.profect.tickle.domain.event.dto.request.CouponCreateRequestDto; import com.profect.tickle.domain.event.dto.request.TicketEventCreateRequestDto; +import com.profect.tickle.domain.event.dto.response.*; import com.profect.tickle.domain.event.entity.EventType; -import com.profect.tickle.domain.event.service.event.CouponService; -import com.profect.tickle.domain.event.service.event.EventService; +import com.profect.tickle.domain.event.service.application.CouponService; +import com.profect.tickle.domain.event.service.application.EventService; +import com.profect.tickle.domain.event.service.rabbitmq.dto.ApplyResponseDto; import com.profect.tickle.global.paging.PagingResponse; import com.profect.tickle.global.response.ResultCode; import com.profect.tickle.global.response.ResultResponse; @@ -68,9 +69,10 @@ public ResultResponse createTicketEvent(@Valid @RequestB content = @Content(schema = @Schema(implementation = TicketApplyResponseDto.class))), @ApiResponse(responseCode = "400", description = "포인트 부족, 중복 응모 등 예외 발생")}) @PostMapping("/ticket/{eventId}") - public ResultResponse applyTicketEvent(@PathVariable Long eventId) { - TicketApplyResponseDto dto = eventService.applyTicketEvent(eventId); - return ResultResponse.of(ResultCode.EVENT_APPLY_SUCCESS, dto); + public ResultResponse applyTicketEvent(@PathVariable Long eventId) { + ApplyResponseDto response = eventService.applyTicketEvent(eventId); + + return ResultResponse.of(ResultCode.EVENT_APPLY_SUCCESS, response); } @Operation(summary = "쿠폰 이벤트 응모", description = "유저가 쿠폰 이벤트에 응모하여 쿠폰을 발급받습니다.", diff --git a/src/main/java/com/profect/tickle/domain/event/dto/response/TicketApplyResponseDto.java b/src/main/java/com/profect/tickle/domain/event/dto/response/TicketApplyResponseDto.java index dab5d770..5b846dc6 100644 --- a/src/main/java/com/profect/tickle/domain/event/dto/response/TicketApplyResponseDto.java +++ b/src/main/java/com/profect/tickle/domain/event/dto/response/TicketApplyResponseDto.java @@ -3,13 +3,6 @@ public record TicketApplyResponseDto( Long eventId, Long memberId, - String message + boolean message ) { - public static TicketApplyResponseDto from(Long eventId, Long memberId) { - return new TicketApplyResponseDto( - eventId, - memberId, - "성공적으로 응모되었습니다. 마이페이지에서 결과를 확인하세요." - ); - } } \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/domain/event/mapper/EventMapper.java b/src/main/java/com/profect/tickle/domain/event/mapper/EventMapper.java index 6f970190..57c28449 100644 --- a/src/main/java/com/profect/tickle/domain/event/mapper/EventMapper.java +++ b/src/main/java/com/profect/tickle/domain/event/mapper/EventMapper.java @@ -1,6 +1,7 @@ package com.profect.tickle.domain.event.mapper; import com.profect.tickle.domain.event.dto.response.*; +import com.profect.tickle.domain.event.entity.Event; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -22,4 +23,6 @@ public interface EventMapper { // 스케쥴러 update mapper 추가 int markEventsAsOngoing(); // 변경된 행 수 리턴 int markEventsAsFinished(); + + List findEventsToMarkAsOngoing(); } diff --git a/src/main/java/com/profect/tickle/domain/event/repository/EventRepository.java b/src/main/java/com/profect/tickle/domain/event/repository/EventRepository.java index 03dfe080..15d1e90a 100644 --- a/src/main/java/com/profect/tickle/domain/event/repository/EventRepository.java +++ b/src/main/java/com/profect/tickle/domain/event/repository/EventRepository.java @@ -1,6 +1,7 @@ package com.profect.tickle.domain.event.repository; import com.profect.tickle.domain.event.entity.Event; +import com.profect.tickle.global.status.Status; import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; @@ -15,12 +16,27 @@ @Repository public interface EventRepository extends JpaRepository { + @Query(""" + SELECT e FROM Event e + JOIN e.seat s + JOIN s.performance p + WHERE e.status.id = :scheduledStatus + AND p.startDate <= CURRENT_TIMESTAMP + AND p.endDate > CURRENT_TIMESTAMP + """) + List findEventsToStart(@Param("scheduledStatus") Long scheduledStatus); interface AccrueRow { Integer getEventAccrued(); Long getStatusId(); } + @Modifying + @Query("UPDATE Event e SET e.accrued = :accrued, e.status = :status WHERE e.id = :eventId") + void updateAccruedAndStatus(@Param("eventId") Long eventId, + @Param("accrued") int accrued, + @Param("status") Status status); + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(value = """ UPDATE event @@ -69,16 +85,8 @@ int incrementAccrued(@Param("eventId") Long eventId, Long findSeatIdByEventId(@Param("eventId") Long eventId);*/ - // Postgres면 증가 후 값을 곧바로 받는 버전 권장 (더 깔끔) - @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query(value = """ - update event - set event_accrued = event_accrued + :delta - where event_id = :eventId and status_id = :inProgress - returning event_accrued - """, nativeQuery = true) - List incrementAccruedReturning(@Param("eventId") Long eventId, - @Param("delta") int delta, - @Param("inProgress") Long inProgress); + @Modifying(clearAutomatically = true) + @Query("UPDATE Event e SET e.status = :status WHERE e.id = :eventId") + int updateEventStatus(@Param("eventId") Long eventId, @Param("status") Status status); } diff --git a/src/main/java/com/profect/tickle/domain/event/scheduler/EventStatusScheduler.java b/src/main/java/com/profect/tickle/domain/event/scheduler/EventStatusScheduler.java index 30419d28..1042e329 100644 --- a/src/main/java/com/profect/tickle/domain/event/scheduler/EventStatusScheduler.java +++ b/src/main/java/com/profect/tickle/domain/event/scheduler/EventStatusScheduler.java @@ -1,22 +1,38 @@ package com.profect.tickle.domain.event.scheduler; +import com.profect.tickle.domain.event.entity.Event; import com.profect.tickle.domain.event.mapper.EventMapper; +import com.profect.tickle.domain.event.repository.EventRepository; +import com.profect.tickle.global.redis.service.RedisInitialize; +import com.profect.tickle.global.status.StatusIds; +import jakarta.annotation.PostConstruct; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import java.util.List; + +@Slf4j @Service @RequiredArgsConstructor public class EventStatusScheduler { - private final EventMapper eventMapper; + private final EventRepository eventRepository; + private final RedisInitialize redisInitialize; - //@Scheduled(cron = "0 * * * * *") // 매 분 0초 + @Scheduled(cron = "0 * * * * *") @Transactional public void syncEventStatuses() { - int toOngoing = eventMapper.markEventsAsOngoing(); - int toFinished = eventMapper.markEventsAsFinished(); - // 필요하면 로그 - // log.info("event status updated: toOngoing={}, toFinished={}", toOngoing, toFinished); + List eventsToStart = eventRepository.findEventsToStart(StatusIds.Event.SCHEDULED); + + log.info("toOngoing={}개 이벤트 Redis 초기화 시작", eventsToStart.size()); + + for (Event event : eventsToStart) { + if (event == null) continue; + log.info("Redis 초기화 대상 eventId={}, goalPrice={}", event.getId(), event.getGoalPrice()); + redisInitialize.initializeRedisEvent(event); + } } } \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/domain/event/service/event/CouponService.java b/src/main/java/com/profect/tickle/domain/event/service/application/CouponService.java similarity index 97% rename from src/main/java/com/profect/tickle/domain/event/service/event/CouponService.java rename to src/main/java/com/profect/tickle/domain/event/service/application/CouponService.java index f0bcba8f..d7937f16 100644 --- a/src/main/java/com/profect/tickle/domain/event/service/event/CouponService.java +++ b/src/main/java/com/profect/tickle/domain/event/service/application/CouponService.java @@ -1,4 +1,4 @@ -package com.profect.tickle.domain.event.service.event; +package com.profect.tickle.domain.event.service.application; import com.profect.tickle.domain.event.dto.response.CouponListResponseDto; import com.profect.tickle.domain.event.dto.response.CouponResponseDto; diff --git a/src/main/java/com/profect/tickle/domain/event/service/event/EventCoreLockService.java b/src/main/java/com/profect/tickle/domain/event/service/application/EventCoreLockService.java similarity index 69% rename from src/main/java/com/profect/tickle/domain/event/service/event/EventCoreLockService.java rename to src/main/java/com/profect/tickle/domain/event/service/application/EventCoreLockService.java index 486b3e42..0d8da65a 100644 --- a/src/main/java/com/profect/tickle/domain/event/service/event/EventCoreLockService.java +++ b/src/main/java/com/profect/tickle/domain/event/service/application/EventCoreLockService.java @@ -1,10 +1,8 @@ -package com.profect.tickle.domain.event.service.event; +package com.profect.tickle.domain.event.service.application; import com.profect.tickle.domain.event.dto.EventDecision; import com.profect.tickle.domain.event.entity.Event; import com.profect.tickle.domain.event.repository.EventRepository; -import com.profect.tickle.domain.member.repository.MemberRepository; -import com.profect.tickle.domain.reservation.repository.SeatRepository; import com.profect.tickle.domain.reservation.service.ReservationService; import com.profect.tickle.global.exception.BusinessException; import com.profect.tickle.global.exception.ErrorCode; @@ -20,49 +18,61 @@ public class EventCoreLockService { private final EventRepository eventRepository; - private final MemberRepository memberRepository; - private final SeatRepository seatRepository; - private final StatusProvider statusProvider; private final ReservationService reservationService; + private final StatusProvider statusProvider; //TODO: 임계영역에 대해서 동시성을 보장하면 원하는 결과가 나올겁니다? //TODO: 영한님의 고급 1편을 보세요. 자바 코드에 대한 동시성을 찾아보세요 @Transactional(propagation = Propagation.REQUIRES_NEW) - public EventDecision applyCore(Long eventId, Long memberId) { - Event event = eventRepository.findById(eventId) + public EventDecision applyCore(Long memberId, Long eventId) { + + long startTotal = System.nanoTime(); + Event event; + long t1, t2, t3; + + // (1) Event 조회 + long start1 = System.nanoTime(); + event = eventRepository.findById(eventId) .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_NOT_FOUND)); - Long statusId = event.getStatus().getId(); + t1 = (System.nanoTime() - start1) / 1_000_000; // ms 단위 + Long statusId = event.getStatus().getId(); if (!StatusIds.Event.IN_PROGRESS.equals(statusId)) { throw new BusinessException(ErrorCode.EVENT_NOT_IN_PROGRESS); } - // 2) 포인트 차감 + // (2) 누적 및 상태 변경 쿼리 + long start2 = System.nanoTime(); short delta = event.getPerPrice(); - var deducted = memberRepository.tryDeductPointReturning(memberId, delta); - if (deducted.isEmpty()) throw new BusinessException(ErrorCode.INSUFFICIENT_POINT); - - // 3) 누적 + 종료 전환(원자) var rows = eventRepository.accrueAndMaybeComplete( eventId, delta, StatusIds.Event.IN_PROGRESS, StatusIds.Event.COMPLETED); + t2 = (System.nanoTime() - start2) / 1_000_000; if (rows.isEmpty()) throw new BusinessException(ErrorCode.EVENT_ALREADY_COMPLETED); var r = rows.get(0); boolean completed = r.getStatusId().equals(StatusIds.Event.COMPLETED); + // (3) 좌석 배정 Long seatId = null; boolean winner = false; - + long start3 = System.nanoTime(); if (completed) { seatId = reservationService.assignSeatForWinner(eventId, memberId); - System.out.println("seatId = " + seatId); - winner = (seatId != null); // ★ 좌석 배정 성공 시에만 winner + winner = (seatId != null); } + t3 = (System.nanoTime() - start3) / 1_000_000; + + long total = (System.nanoTime() - startTotal) / 1_000_000; return new EventDecision(eventId, memberId, delta, winner, r.getEventAccrued(), seatId); } + + @Transactional + public void completeEvent(Long eventId) { + eventRepository.updateEventStatus(eventId, statusProvider.provide(StatusIds.Event.COMPLETED)); + } } \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/domain/event/service/application/EventPreconditionService.java b/src/main/java/com/profect/tickle/domain/event/service/application/EventPreconditionService.java new file mode 100644 index 00000000..afa42c97 --- /dev/null +++ b/src/main/java/com/profect/tickle/domain/event/service/application/EventPreconditionService.java @@ -0,0 +1,38 @@ +package com.profect.tickle.domain.event.service.application; + +import com.profect.tickle.domain.event.entity.Event; +import com.profect.tickle.domain.event.repository.EventRepository; +import com.profect.tickle.domain.member.repository.MemberRepository; +import com.profect.tickle.global.exception.BusinessException; +import com.profect.tickle.global.exception.ErrorCode; +import com.profect.tickle.global.status.StatusIds; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EventPreconditionService { + + private final EventRepository eventRepository; + private final MemberRepository memberRepository; + + @Transactional(readOnly = true) + public void validatePreApplyConditions(Long memberId, Long eventId) { + Event event = eventRepository.findById(eventId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_NOT_FOUND)); + + int perPrice = event.getPerPrice(); + int currentPoint = memberRepository.findPointById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + + if (currentPoint < perPrice) + throw new BusinessException(ErrorCode.INSUFFICIENT_POINT); + + if (!StatusIds.Event.IN_PROGRESS.equals(event.getStatus().getId())) + throw new BusinessException(ErrorCode.EVENT_NOT_IN_PROGRESS); + + } +} \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/domain/event/service/event/EventService.java b/src/main/java/com/profect/tickle/domain/event/service/application/EventService.java similarity index 85% rename from src/main/java/com/profect/tickle/domain/event/service/event/EventService.java rename to src/main/java/com/profect/tickle/domain/event/service/application/EventService.java index 2fe9e4e3..aa020c31 100644 --- a/src/main/java/com/profect/tickle/domain/event/service/event/EventService.java +++ b/src/main/java/com/profect/tickle/domain/event/service/application/EventService.java @@ -1,9 +1,10 @@ -package com.profect.tickle.domain.event.service.event; +package com.profect.tickle.domain.event.service.application; import com.profect.tickle.domain.event.dto.request.CouponCreateRequestDto; import com.profect.tickle.domain.event.dto.request.TicketEventCreateRequestDto; import com.profect.tickle.domain.event.dto.response.*; import com.profect.tickle.domain.event.entity.EventType; +import com.profect.tickle.domain.event.service.rabbitmq.dto.ApplyResponseDto; import com.profect.tickle.global.paging.PagingResponse; import java.time.LocalDate; @@ -14,7 +15,7 @@ public interface EventService { TicketEventResponseDto createTicketEvent(TicketEventCreateRequestDto request); - TicketApplyResponseDto applyTicketEvent(Long eventId); + ApplyResponseDto applyTicketEvent(Long eventId); PagingResponse getEventList(EventType type, int page, int size); diff --git a/src/main/java/com/profect/tickle/domain/event/service/event/PostActionsService.java b/src/main/java/com/profect/tickle/domain/event/service/application/PostActionsService.java similarity index 85% rename from src/main/java/com/profect/tickle/domain/event/service/event/PostActionsService.java rename to src/main/java/com/profect/tickle/domain/event/service/application/PostActionsService.java index 962058b2..4aa2efcb 100644 --- a/src/main/java/com/profect/tickle/domain/event/service/event/PostActionsService.java +++ b/src/main/java/com/profect/tickle/domain/event/service/application/PostActionsService.java @@ -1,4 +1,4 @@ -package com.profect.tickle.domain.event.service.event; +package com.profect.tickle.domain.event.service.application; import com.profect.tickle.domain.member.entity.Member; import com.profect.tickle.domain.member.repository.MemberRepository; @@ -10,6 +10,8 @@ import com.profect.tickle.domain.reservation.entity.Seat; import com.profect.tickle.domain.reservation.repository.ReservationRepository; import com.profect.tickle.domain.reservation.repository.SeatRepository; +import com.profect.tickle.global.exception.BusinessException; +import com.profect.tickle.global.exception.ErrorCode; import com.profect.tickle.global.status.Status; import com.profect.tickle.global.status.StatusIds; import com.profect.tickle.global.status.service.StatusProvider; @@ -31,14 +33,18 @@ public class PostActionsService { private final PerformanceRepository performanceRepository; private final StatusProvider statusProvider; - @Transactional(propagation = Propagation.REQUIRES_NEW) + + @Transactional public void recordPointHistory(Long memberId, int amount, PointTarget target) { + var deducted = memberRepository.tryDeductPointReturning(memberId, amount); + if (deducted.isEmpty()) throw new BusinessException(ErrorCode.INSUFFICIENT_POINT); + Member member = memberRepository.getReferenceById(memberId); Point p = Point.deduct(member, amount, target); pointRepository.save(p); } - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional public void reserveSeatAndCreateReservation(Long seatId, Long memberId, int accrued) { Long perfId = seatRepository.findPerformanceIdBySeatId(seatId); Reservation r = Reservation.create( diff --git a/src/main/java/com/profect/tickle/domain/event/service/event/impl/EventServiceImpl.java b/src/main/java/com/profect/tickle/domain/event/service/application/impl/EventServiceImpl.java similarity index 90% rename from src/main/java/com/profect/tickle/domain/event/service/event/impl/EventServiceImpl.java rename to src/main/java/com/profect/tickle/domain/event/service/application/impl/EventServiceImpl.java index 51f63b6a..4ca9a137 100644 --- a/src/main/java/com/profect/tickle/domain/event/service/event/impl/EventServiceImpl.java +++ b/src/main/java/com/profect/tickle/domain/event/service/application/impl/EventServiceImpl.java @@ -1,4 +1,4 @@ -package com.profect.tickle.domain.event.service.event.impl; +package com.profect.tickle.domain.event.service.application.impl; import com.profect.tickle.domain.event.dto.request.CouponCreateRequestDto; import com.profect.tickle.domain.event.dto.request.TicketEventCreateRequestDto; @@ -11,11 +11,11 @@ import com.profect.tickle.domain.event.mapper.EventMapper; import com.profect.tickle.domain.event.repository.CouponRepository; import com.profect.tickle.domain.event.repository.EventRepository; -import com.profect.tickle.domain.event.service.event.EventService; -import com.profect.tickle.domain.event.service.lock.PessimisticEventApplyExecutor; -import com.profect.tickle.domain.event.service.message.publisher.EventPublisher; -import com.profect.tickle.domain.event.stream.dto.EventMessage; -import com.profect.tickle.domain.event.stream.producer.EventProducer; +import com.profect.tickle.domain.event.service.application.EventPreconditionService; +import com.profect.tickle.domain.event.service.application.EventService; +import com.profect.tickle.domain.event.service.rabbitmq.dto.ApplyResponseDto; +import com.profect.tickle.domain.event.service.rabbitmq.producer.TicketEventProducer; +import com.profect.tickle.domain.member.repository.MemberRepository; import com.profect.tickle.domain.performance.entity.Performance; import com.profect.tickle.domain.performance.repository.PerformanceRepository; import com.profect.tickle.domain.point.entity.PointTarget; @@ -44,12 +44,9 @@ @RequiredArgsConstructor public class EventServiceImpl implements EventService { - // utils - private final PointTarget eventTarget = PointTarget.EVENT; private final Clock clock; private final ZoneId zone = ZoneId.systemDefault(); - // mapper & repositories private final PessimisticEventApplyExecutor pessimisticEventApplyExecutor; private final SeatRepository seatRepository; private final CouponRepository couponRepository; @@ -59,8 +56,8 @@ public class EventServiceImpl implements EventService { private final CouponReceivedMapper couponReceivedMapper; private final PerformanceRepository performanceRepository; private final StatusProvider statusProvider; - private final EventPublisher eventPublisher; - private final EventProducer producer; + private final TicketEventProducer eventProducer; + private final EventPreconditionService preconditionService; @Override @@ -104,14 +101,17 @@ public TicketEventResponseDto createTicketEvent(TicketEventCreateRequestDto requ } @Override - public TicketApplyResponseDto applyTicketEvent(Long eventId) { + public ApplyResponseDto applyTicketEvent(Long eventId) { Long memberId = SecurityUtil.getSignInMemberId(); - producer.appendToStream(new EventMessage(eventId, memberId)); + preconditionService.validatePreApplyConditions(memberId, eventId); - return TicketApplyResponseDto.from(eventId, memberId); + eventProducer.sendApplyRequest(memberId, eventId); + + return ApplyResponseDto.queued(); } + @Override @Transactional(readOnly = true) public PagingResponse getEventList(EventType type, int page, int size) { diff --git a/src/main/java/com/profect/tickle/domain/event/service/lock/PessimisticEventApplyExecutor.java b/src/main/java/com/profect/tickle/domain/event/service/application/impl/PessimisticEventApplyExecutor.java similarity index 93% rename from src/main/java/com/profect/tickle/domain/event/service/lock/PessimisticEventApplyExecutor.java rename to src/main/java/com/profect/tickle/domain/event/service/application/impl/PessimisticEventApplyExecutor.java index c8dfa2f7..31470783 100644 --- a/src/main/java/com/profect/tickle/domain/event/service/lock/PessimisticEventApplyExecutor.java +++ b/src/main/java/com/profect/tickle/domain/event/service/application/impl/PessimisticEventApplyExecutor.java @@ -1,4 +1,4 @@ -package com.profect.tickle.domain.event.service.lock; +package com.profect.tickle.domain.event.service.application.impl; import com.profect.tickle.domain.event.entity.Coupon; import com.profect.tickle.domain.event.entity.Event; @@ -17,7 +17,6 @@ import com.profect.tickle.global.status.StatusIds; import com.profect.tickle.global.status.service.StatusProvider; import lombok.RequiredArgsConstructor; -import org.redisson.api.RedissonClient; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; @@ -75,10 +74,6 @@ private Event getEventOrThrow(Long eventId) { .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_NOT_FOUND)); } - private Seat getSeatOrThrow(Long eventSeatId) { - return seatRepository.findById(eventSeatId) - .orElseThrow(() -> new BusinessException(ErrorCode.SEAT_NOT_FOUND)); - } private Member getMemberOrThrow() { Long memberId = SecurityUtil.getSignInMemberId(); diff --git a/src/main/java/com/profect/tickle/domain/event/service/lock/EventApplyExecutor.java b/src/main/java/com/profect/tickle/domain/event/service/lock/EventApplyExecutor.java deleted file mode 100644 index 98fd13b3..00000000 --- a/src/main/java/com/profect/tickle/domain/event/service/lock/EventApplyExecutor.java +++ /dev/null @@ -1,91 +0,0 @@ -/* -package com.profect.tickle.domain.event.service.lock; - -import com.profect.tickle.domain.event.dto.response.TicketApplyResponseDto; -import com.profect.tickle.domain.event.entity.Event; -import com.profect.tickle.domain.event.repository.EventRepository; -import com.profect.tickle.domain.member.entity.Member; -import com.profect.tickle.domain.member.repository.MemberRepository; -import com.profect.tickle.domain.performance.repository.PerformanceRepository; -import com.profect.tickle.domain.point.entity.Point; -import com.profect.tickle.domain.point.entity.PointTarget; -import com.profect.tickle.domain.point.repository.PointRepository; -import com.profect.tickle.domain.reservation.entity.Reservation; -import com.profect.tickle.domain.reservation.entity.Seat; -import com.profect.tickle.domain.reservation.repository.ReservationRepository; -import com.profect.tickle.domain.reservation.repository.SeatRepository; -import com.profect.tickle.global.exception.BusinessException; -import com.profect.tickle.global.exception.ErrorCode; -import com.profect.tickle.global.security.util.SecurityUtil; -import com.profect.tickle.global.status.Status; -import com.profect.tickle.global.status.StatusIds; -import com.profect.tickle.global.status.service.StatusProvider; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class EventApplyExecutor { - private final PointTarget eventTarget = PointTarget.EVENT; - - private final SeatRepository seatRepository; - private final EventRepository eventRepository; - private final MemberRepository memberRepository; - private final ReservationRepository reservationRepository; - private final PointRepository pointRepository; - - private final PerformanceRepository performanceRepository; - private final StatusProvider statusProvider; - - @Transactional(propagation = Propagation.REQUIRES_NEW) - public TicketApplyResponseDto applyTicketEventOnce(Long eventId) { - Event event = getEventOrThrow(eventId); // @Version 필드 포함 - Member member = getMemberOrThrow(); - - // 실제 로직이 진행되기 전에 진행 상태 검사 - if (!StatusIds.Event.IN_PROGRESS.equals(event.getStatus().getId())) { - throw new BusinessException(ErrorCode.EVENT_NOT_IN_PROGRESS); - } - - Point point = member.deductPoint(event.getPerPrice(), eventTarget); - pointRepository.save(point); - - // 가능하면 아래 한 줄로(방법 B) 대체 권장. 엔티티 변경을 고수한다면 여기서 예외 날 수 있음. - event.accumulate(event.getPerPrice()); - - boolean isWinner = (event.getAccrued() >= event.getGoalPrice()); - if (isWinner) { - Seat seat = getSeatOrThrow(event.getSeat().getId()); - event.updateStatus(statusProvider.provide(StatusIds.Event.COMPLETED)); - - Status paidStatus = statusProvider.provide(StatusIds.Reservation.PAID); - Reservation reservation = Reservation.create(member, seat.getPerformance(), paidStatus, event.getAccrued()); - reservation.assignSeat(seat); - - Status reservedStatus = statusProvider.provide(StatusIds.Seat.RESERVED); - seat.completeReservation(member, reservedStatus, null); - - reservationRepository.save(reservation); - } - return TicketApplyResponseDto.from(eventId, member.getId(), isWinner); - } - - private Event getEventOrThrow(Long eventId) { - return eventRepository.findById(eventId) - .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_NOT_FOUND)); - } - - private Seat getSeatOrThrow(Long eventSeatId) { - return seatRepository.findById(eventSeatId) - .orElseThrow(() -> new BusinessException(ErrorCode.SEAT_NOT_FOUND)); - } - - private Member getMemberOrThrow() { - Long memberId = SecurityUtil.getSignInMemberId(); - return memberRepository.findById(memberId) - .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); - } -} -*/ diff --git a/src/main/java/com/profect/tickle/domain/event/service/lock/LockManager.java b/src/main/java/com/profect/tickle/domain/event/service/lock/LockManager.java deleted file mode 100644 index 4d567914..00000000 --- a/src/main/java/com/profect/tickle/domain/event/service/lock/LockManager.java +++ /dev/null @@ -1,93 +0,0 @@ -/* -package com.profect.tickle.domain.event.service.lock; - -import com.profect.tickle.domain.event.dto.EventDecision; -import com.profect.tickle.domain.event.service.event.EventCoreLockService; -import com.profect.tickle.domain.event.service.event.PostActionsService; -import com.profect.tickle.domain.event.service.message.dto.TicketLockMessage; -import com.profect.tickle.domain.point.entity.PointTarget; -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.redisson.api.RLock; -import org.redisson.api.RTopic; -import org.redisson.api.RedissonClient; -import org.springframework.stereotype.Service; - -import java.util.concurrent.ConcurrentHashMap; - -@Slf4j -@Service -@RequiredArgsConstructor -public class LockManager { - - private final RedissonClient redisson; - private final EventCoreLockService core; - private final PostActionsService post; - - // 이미 구독 중인 채널 관리 - private final ConcurrentHashMap subscribedChannels = new ConcurrentHashMap<>(); - - @PostConstruct - public void init() { - // 공용 구독 채널 (모든 이벤트 메시지를 수신) - RTopic commonTopic = redisson.getTopic("ticketLockChannel"); - commonTopic.addListener(TicketLockMessage.class, (ch, msg) -> handleMessage(msg)); - } - - private void handleMessage(TicketLockMessage msg) { - // 메시지 수신 시 락 시도 - attemptLock(msg); - } - - public void attemptLock(TicketLockMessage msg) { - String lockKey = "ticket-lock:" + msg.eventId(); - RLock lock = redisson.getLock(lockKey); - - lock.tryLockAsync() - .thenAccept(acquired -> { - if (acquired) { - log.info("[LockManager] 락 획득: member={}, event={}", msg.memberId(), msg.eventId()); - processCore(msg) - .thenRun(() -> { - lock.unlockAsync().thenRun(() -> { - log.info("[LockManager] 락 해제: member={}, event={}", msg.memberId(), msg.eventId()); - // 재시도 필요 시 다른 구독자에게 알림 - publishRetry(msg); - }); - }); - } else { - log.info("[LockManager] 락 획득 실패: member={}, event={}", msg.memberId(), msg.eventId()); - publishRetry(msg); // 락 실패 메시지 발행 - } - }) - .exceptionally(ex -> { - log.error("[LockManager] 락 시도 중 오류: member={}, event={}", msg.memberId(), msg.eventId(), ex); - return null; - }); - } - - private java.util.concurrent.CompletableFuture processCore(TicketLockMessage msg) { - return java.util.concurrent.CompletableFuture.runAsync(() -> { - // 핵심 로직 - EventDecision result = core.applyCore(msg.eventId(), msg.memberId()); - // 포인트/좌석 처리 - postAfterCore(result); - }); - } - - private void postAfterCore(EventDecision result) { - // 포인트 기록 - post.recordPointHistory(result.memberId(), result.perPrice(), PointTarget.EVENT); - - // 승자 좌석 예약 - if (result.seatId() != null) - post.reserveSeatAndCreateReservation(result.seatId(), result.memberId(), result.accrued()); - } - - private void publishRetry(TicketLockMessage msg) { - String channelName = "ticketLockChannel"; - RTopic topic = redisson.getTopic(channelName); - topic.publish(msg); - } -}*/ diff --git a/src/main/java/com/profect/tickle/domain/event/service/lock/TicketAppliedListener.java b/src/main/java/com/profect/tickle/domain/event/service/lock/TicketAppliedListener.java deleted file mode 100644 index c8648755..00000000 --- a/src/main/java/com/profect/tickle/domain/event/service/lock/TicketAppliedListener.java +++ /dev/null @@ -1,59 +0,0 @@ -/* -package com.profect.tickle.domain.event.service.lock; - -import com.profect.tickle.domain.event.entity.TicketApplied; -import com.profect.tickle.domain.point.entity.PointTarget; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.redisson.api.RLock; -import org.redisson.api.RedissonClient; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -import java.util.concurrent.TimeUnit; - -@Slf4j -@Component -@RequiredArgsConstructor -public class TicketAppliedListener { - - private final PostActionsService post; - private final RedissonClient redisson; - - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void afterCommit(TicketAppliedDto e) { - // seatId를 기준으로 락을 걸면, 같은 좌석에 동시에 접근하는 요청을 막을 수 있음 - String lockKey = "seat-lock:" + e.seatId(); - RLock lock = redisson.getLock(lockKey); - - try { - // 락 획득 최대 5초, 획득하면 10초 동안 유지 - if (lock.tryLock(5, 10, TimeUnit.SECONDS)) { - try { - log.info("[TicketAppliedListener] lock acquired member={}, seat={}", e.memberId(), e.seatId()); - - // 1️⃣ 포인트 적립 - post.recordPointHistory(e.memberId(), e.perPrice(), e.target()); - - // 2️⃣ 좌석 예약 - if (e.isWinner() && e.seatId() != null) { - post.reserveSeatAndCreateReservation(e.seatId(), e.memberId(), e.accrued()); - } - - } finally { - lock.unlock(); - log.info("[TicketAppliedListener] lock released member={}, seat={}", e.memberId(), e.seatId()); - } - } else { - log.warn("[TicketAppliedListener] could not acquire lock, member={}, seat={}", e.memberId(), e.seatId()); - // 락 획득 실패 시 재시도 로직을 추가하거나 예외 처리 가능 - } - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - log.error("[TicketAppliedListener] lock interrupted, member={}, seat={}", e.memberId(), e.seatId(), ex); - } - } -}*/ diff --git a/src/main/java/com/profect/tickle/domain/event/service/message/dto/TicketAppliedDto.java b/src/main/java/com/profect/tickle/domain/event/service/message/dto/TicketAppliedDto.java deleted file mode 100644 index 872259fa..00000000 --- a/src/main/java/com/profect/tickle/domain/event/service/message/dto/TicketAppliedDto.java +++ /dev/null @@ -1,16 +0,0 @@ -/* -package com.profect.tickle.domain.event.service.lock; - -import com.profect.tickle.domain.point.entity.PointTarget; - -public record TicketAppliedDto( - Long eventId, - Long memberId, - Long seatId, - short perPrice, - int accrued, - PointTarget target, - boolean isWinner -) { -} -*/ diff --git a/src/main/java/com/profect/tickle/domain/event/service/message/dto/TicketLockMessage.java b/src/main/java/com/profect/tickle/domain/event/service/message/dto/TicketLockMessage.java deleted file mode 100644 index 41248d51..00000000 --- a/src/main/java/com/profect/tickle/domain/event/service/message/dto/TicketLockMessage.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.profect.tickle.domain.event.service.message.dto; - -import com.fasterxml.jackson.annotation.JsonTypeInfo; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - - -@Getter -@NoArgsConstructor -@AllArgsConstructor -@JsonTypeInfo( - use = JsonTypeInfo.Id.CLASS, - include = JsonTypeInfo.As.PROPERTY, - property = "@class" -) -public class TicketLockMessage{ - Long eventId; - Long memberId; -} \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/domain/event/service/message/publisher/EventPublisher.java b/src/main/java/com/profect/tickle/domain/event/service/message/publisher/EventPublisher.java deleted file mode 100644 index bdd41f1c..00000000 --- a/src/main/java/com/profect/tickle/domain/event/service/message/publisher/EventPublisher.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.profect.tickle.domain.event.service.message.publisher; - -import com.profect.tickle.domain.event.service.message.dto.TicketLockMessage; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.redisson.api.RTopic; -import org.redisson.api.RedissonClient; -import org.redisson.codec.JsonJacksonCodec; -import org.springframework.stereotype.Service; - -@Slf4j -@Service -@RequiredArgsConstructor -public class EventPublisher { - private final RedissonClient redissonClient; - private static final String TOPIC_NAME = "ticket-event-topic"; - - public void publish(TicketLockMessage msg) { - RTopic topic = redissonClient.getTopic(TOPIC_NAME, new JsonJacksonCodec()); - topic.publishAsync(msg) - .thenAccept(r -> log.info("Published ticket event: {}", msg)); - } -} \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/domain/event/service/message/subscriber/EventSubscriber.java b/src/main/java/com/profect/tickle/domain/event/service/message/subscriber/EventSubscriber.java deleted file mode 100644 index aec416f6..00000000 --- a/src/main/java/com/profect/tickle/domain/event/service/message/subscriber/EventSubscriber.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.profect.tickle.domain.event.service.message.subscriber; - -import com.profect.tickle.domain.event.dto.EventDecision; -import com.profect.tickle.domain.event.service.event.EventCoreLockService; -import com.profect.tickle.domain.event.service.event.PostActionsService; -import com.profect.tickle.domain.event.service.message.dto.TicketLockMessage; -import com.profect.tickle.domain.point.entity.PointTarget; -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.redisson.api.RLock; -import org.redisson.api.RTopic; -import org.redisson.api.RedissonClient; -import org.redisson.codec.JsonJacksonCodec; -import org.springframework.stereotype.Service; - -import java.util.concurrent.TimeUnit; - -@Slf4j -@Service -@RequiredArgsConstructor -public class EventSubscriber { - - private final RedissonClient redissonClient; - private final EventCoreLockService coreLockService; - private final PostActionsService postActionsService; - - private static final String TOPIC_NAME = "ticket-event-topic"; - - @PostConstruct - public void subscribe() { - RTopic topic = redissonClient.getTopic(TOPIC_NAME, new JsonJacksonCodec()); - - topic.addListener(TicketLockMessage.class, (channel, msg) -> { - log.info("Received ticket event message: {}", msg); - - String lockName = "ticket-lock:" + msg.getEventId(); - RLock lock = redissonClient.getLock(lockName); - - lock.lockAsync(10, TimeUnit.SECONDS).thenRunAsync(() -> { - try { - // 핵심 로직 적용 - EventDecision decision = coreLockService.applyCore(msg.getEventId(), msg.getMemberId()); - - // 후속 처리 - postActionsService.recordPointHistory(msg.getMemberId(), decision.perPrice(), PointTarget.EVENT); - - if (decision.winner()) { - postActionsService.reserveSeatAndCreateReservation( - decision.seatId(), msg.getMemberId(), decision.perPrice()); - } - - } catch (Exception e) { - log.error("Error processing ticket event: {}", e.getMessage(), e); - } finally { - lock.unlockAsync(); - } - }); - }); - } -} \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/EventAccumulatorFlushScheduler.java b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/EventAccumulatorFlushScheduler.java new file mode 100644 index 00000000..946b4e48 --- /dev/null +++ b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/EventAccumulatorFlushScheduler.java @@ -0,0 +1,17 @@ +package com.profect.tickle.domain.event.service.rabbitmq; + +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class EventAccumulatorFlushScheduler { + + private final EventAccumulatorFlushService flushService; + + @Scheduled(fixedDelay = 30_000) + public void scheduleFlush() { + flushService.flushToDB(); + } +} \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/EventAccumulatorFlushService.java b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/EventAccumulatorFlushService.java new file mode 100644 index 00000000..5a026e86 --- /dev/null +++ b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/EventAccumulatorFlushService.java @@ -0,0 +1,58 @@ +package com.profect.tickle.domain.event.service.rabbitmq; + +import com.profect.tickle.domain.event.repository.EventRepository; +import com.profect.tickle.global.status.Status; +import com.profect.tickle.global.status.StatusIds; +import com.profect.tickle.global.status.service.StatusProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class EventAccumulatorFlushService { + + private final RedisTemplate redisTemplate; + private final EventRepository eventRepository; + private final StatusProvider statusProvider; + + @Transactional + public void flushToDB() { + Set keys = redisTemplate.keys("event:*"); + if (keys.isEmpty()) return; + + for (String key : keys) { + try { + Long eventId = Long.parseLong(key.replace("event:", "")); + Object accruedObj = redisTemplate.opsForHash().get(key, "target"); + Object statusIdObj = redisTemplate.opsForHash().get(key, "statusId"); // status → statusId 로 변경 + + if (accruedObj == null) continue; + + int accrued = Integer.parseInt(accruedObj.toString()); + Long statusId = (statusIdObj != null) + ? Long.parseLong(statusIdObj.toString()) + : StatusIds.Event.IN_PROGRESS; + + Status status = statusProvider.provide(statusId); + + eventRepository.updateAccruedAndStatus(eventId, accrued, status); + log.error("💾 [Flush] DB 반영 완료 eventId={}, accrued={}, status={}", eventId, accrued, status.getCode()); + + // 완료된 이벤트는 Redis에서 삭제 + if (StatusIds.Event.COMPLETED.equals(statusId)) { + redisTemplate.delete(key); + log.info("🧹 Redis에서 이벤트 삭제 eventId={}", eventId); + } + + } catch (Exception e) { + log.error("❌ Flush 중 오류 key={}", key, e); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/consumer/PostActionsConsumer.java b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/consumer/PostActionsConsumer.java new file mode 100644 index 00000000..680f0dca --- /dev/null +++ b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/consumer/PostActionsConsumer.java @@ -0,0 +1,29 @@ +package com.profect.tickle.domain.event.service.rabbitmq.consumer; + +import com.profect.tickle.domain.event.service.application.PostActionsService; +import com.profect.tickle.domain.event.service.rabbitmq.dto.PostPointHistoryMessage; +import com.profect.tickle.domain.event.service.rabbitmq.dto.PostReservationMessage; +import com.profect.tickle.domain.point.entity.PointTarget; +import com.profect.tickle.global.rabbitMQ.config.RabbitMQEventConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PostActionsConsumer { + + private final PostActionsService postActionsService; + + @RabbitListener(queues = RabbitMQEventConfig.POST_POINT_QUEUE, concurrency = "8") + public void handlePointHistory(PostPointHistoryMessage msg) { + postActionsService.recordPointHistory(msg.memberId(), msg.perPrice(), PointTarget.EVENT); + } + + @RabbitListener(queues = RabbitMQEventConfig.POST_RESERVATION_QUEUE, concurrency = "8") + public void handleReservation(PostReservationMessage msg) { + postActionsService.reserveSeatAndCreateReservation(msg.seatId(), msg.memberId(), msg.accrued()); + } +} \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/consumer/TicketEventConsumer.java b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/consumer/TicketEventConsumer.java new file mode 100644 index 00000000..e102f637 --- /dev/null +++ b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/consumer/TicketEventConsumer.java @@ -0,0 +1,85 @@ +package com.profect.tickle.domain.event.service.rabbitmq.consumer; + +import com.profect.tickle.domain.event.service.application.EventCoreLockService; +import com.profect.tickle.domain.event.service.rabbitmq.dto.ApplyRequestDto; +import com.profect.tickle.domain.event.service.rabbitmq.dto.PostPointHistoryMessage; +import com.profect.tickle.domain.event.service.rabbitmq.dto.PostReservationMessage; +import com.profect.tickle.domain.event.service.rabbitmq.producer.PostActionsProducer; +import com.profect.tickle.global.status.StatusIds; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; + +@Slf4j +@Component +public class TicketEventConsumer { + + private final RedisTemplate redisTemplate; + private final PostActionsProducer postProducer; + private final EventCoreLockService eventCoreLockService; + private final String luaScript; + + public TicketEventConsumer(RedisTemplate redisTemplate, + PostActionsProducer postProducer, + EventCoreLockService eventCoreLockService) throws IOException { + this.redisTemplate = redisTemplate; + this.postProducer = postProducer; + this.eventCoreLockService = eventCoreLockService; + + try (InputStream is = new ClassPathResource("scripts/event_decrement.lua").getInputStream()) { + this.luaScript = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + log.info("Loaded Lua script for event decrement logic."); + } + + + @RabbitListener( + queues = { + "event.shard.queue.0", "event.shard.queue.1", + "event.shard.queue.2", "event.shard.queue.3", + "event.shard.queue.4", "event.shard.queue.5", + "event.shard.queue.6", "event.shard.queue.7" + }, + concurrency = "8" // 샤드당 순차 처리 + ) + public void handleTicketApply(ApplyRequestDto request) { + Long eventId = request.getEventId(); + Long memberId = request.getMemberId(); + String key = "event:" + eventId; + + try { + // 1. Redis Lua Script 실행 + DefaultRedisScript script = new DefaultRedisScript<>(luaScript, Long.class); + Long newAccrued = redisTemplate.execute( + script, + Collections.singletonList(key), + request.getPerPrice(), // ARGV[1] : perPrice + StatusIds.Event.COMPLETED // ARGV[2] : COMPLETED + ); + + if (newAccrued == -99999) { + log.error("이미 종료된 이벤트입니다. eventId={}, memberId={}", eventId, memberId); + return; + } + // 3. 후처리 (비동기 메시지 발행) + postProducer.sendPointHistory(new PostPointHistoryMessage(memberId, request.getPerPrice())); + + if (newAccrued <= 0) { + // Redis 내부에서는 이미 status=COMPLETED 로 변경됨 + eventCoreLockService.completeEvent(eventId); + postProducer.sendReservation(new PostReservationMessage(memberId, null, 0)); + log.info("이벤트 종료 처리 완료! eventId={}, 남은 목표금액={}", eventId, newAccrued); + } + } catch (Exception e) { + log.error("Redis Lua 처리 실패 eventId={}, memberId={}", eventId, memberId, e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/dto/ApplyRequestDto.java b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/dto/ApplyRequestDto.java new file mode 100644 index 00000000..116028c6 --- /dev/null +++ b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/dto/ApplyRequestDto.java @@ -0,0 +1,27 @@ +package com.profect.tickle.domain.event.service.rabbitmq.dto; + +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class ApplyRequestDto { + private Long eventId; + private Long memberId; + private Integer goalPrice; + private Integer accrued; + private Short perPrice; + + private ApplyRequestDto(Long eventId, Long memberId, Integer goalPrice, Integer accrued, Short perPrice) { + this.eventId = eventId; + this.memberId = memberId; + this.goalPrice = goalPrice; + this.accrued = accrued; + this.perPrice = perPrice; + } + + public static ApplyRequestDto create(Long eventId, Long memberId, Integer goalPrice, Integer accrued, Short perPrice) { + return new ApplyRequestDto(eventId, memberId, goalPrice, accrued, perPrice); + } +} \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/dto/ApplyResponseDto.java b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/dto/ApplyResponseDto.java new file mode 100644 index 00000000..4b4646bc --- /dev/null +++ b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/dto/ApplyResponseDto.java @@ -0,0 +1,34 @@ +package com.profect.tickle.domain.event.service.rabbitmq.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class ApplyResponseDto { + private boolean success; + private boolean winner; + private String message; + + private ApplyResponseDto(boolean success, boolean winner, String message) { + this.success = success; + this.winner = winner; + this.message = message; + } + + public static ApplyResponseDto create(boolean success, boolean winner, String message) { + return new ApplyResponseDto(success, winner, message); + } + + public static ApplyResponseDto success(boolean winner) { + return create(true, winner, "응모 성공"); + } + + public static ApplyResponseDto fail(String message) { + return create(false, false, message); + } + + public static ApplyResponseDto queued() { + return create(true, false, "응모 요청이 접수되었습니다."); + } +} \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/dto/PostActionsMessage.java b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/dto/PostActionsMessage.java new file mode 100644 index 00000000..d59e1318 --- /dev/null +++ b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/dto/PostActionsMessage.java @@ -0,0 +1,56 @@ +/* +package com.profect.tickle.domain.event.service.rabbitmq.dto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +*/ +/** + * Core 처리 후 후속(Post) 단계에서 사용하는 메시지 DTO + * - 포인트 차감 및 예매권 발급(우승자일 경우)을 위한 데이터 전송용 + *//* + +@Getter +@Builder +@ToString +@NoArgsConstructor +@AllArgsConstructor +public class PostActionsMessage { + + */ +/** + * 응모한 사용자 ID + *//* + + private Long memberId; + + */ +/** + * 이벤트 1회 참여 시 차감 포인트 + *//* + + private int perPrice; + + */ +/** + * 당첨 여부 + *//* + + private boolean winner; + + */ +/** + * 좌석 ID (우승자만 존재) + *//* + + private Long seatId; + + */ +/** + * 누적 포인트 (좌석 예약 시 사용) + *//* + + private int accrued; +}*/ diff --git a/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/dto/PostPointHistoryMessage.java b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/dto/PostPointHistoryMessage.java new file mode 100644 index 00000000..aa3a45df --- /dev/null +++ b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/dto/PostPointHistoryMessage.java @@ -0,0 +1,6 @@ +package com.profect.tickle.domain.event.service.rabbitmq.dto; + +public record PostPointHistoryMessage( + Long memberId, + int perPrice +) {} \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/dto/PostReservationMessage.java b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/dto/PostReservationMessage.java new file mode 100644 index 00000000..a2ef8c37 --- /dev/null +++ b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/dto/PostReservationMessage.java @@ -0,0 +1,7 @@ +package com.profect.tickle.domain.event.service.rabbitmq.dto; + +public record PostReservationMessage( + Long memberId, + Long seatId, + int accrued +) {} \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/producer/PostActionsProducer.java b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/producer/PostActionsProducer.java new file mode 100644 index 00000000..0c105bf5 --- /dev/null +++ b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/producer/PostActionsProducer.java @@ -0,0 +1,41 @@ +package com.profect.tickle.domain.event.service.rabbitmq.producer; + +import com.profect.tickle.domain.event.service.rabbitmq.dto.PostPointHistoryMessage; +import com.profect.tickle.domain.event.service.rabbitmq.dto.PostReservationMessage; +import com.profect.tickle.global.rabbitMQ.config.RabbitMQEventConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PostActionsProducer { + + private final RabbitTemplate rabbitTemplate; + + /** + * 1️⃣ 포인트 이력 후속 작업 전송 + */ + public void sendPointHistory(PostPointHistoryMessage message) { + log.info("📨 [PostActionsProducer] 포인트 이력 후속 작업 전송: {}", message); + rabbitTemplate.convertAndSend( + RabbitMQEventConfig.POST_POINT_EXCHANGE, + RabbitMQEventConfig.POST_POINT_ROUTING_KEY, + message + ); + } + + /** + * 2️⃣ 좌석 예약 후속 작업 전송 + */ + public void sendReservation(PostReservationMessage message) { + log.info("📨 [PostActionsProducer] 좌석 예약 후속 작업 전송: {}", message); + rabbitTemplate.convertAndSend( + RabbitMQEventConfig.POST_RESERVATION_EXCHANGE, + RabbitMQEventConfig.POST_RESERVATION_ROUTING_KEY, + message + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/producer/TicketApplyBatchProcessor.java b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/producer/TicketApplyBatchProcessor.java new file mode 100644 index 00000000..78cafad9 --- /dev/null +++ b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/producer/TicketApplyBatchProcessor.java @@ -0,0 +1,68 @@ +/* +package com.profect.tickle.domain.event.service.rabbitmq.producer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.profect.tickle.domain.event.dto.EventDecision; +import com.profect.tickle.domain.event.service.application.EventCoreLockService; +import com.profect.tickle.domain.event.service.rabbitmq.dto.ApplyRequestDto; +import com.profect.tickle.domain.event.service.rabbitmq.dto.PostPointHistoryMessage; +import com.profect.tickle.domain.event.service.rabbitmq.dto.PostReservationMessage; +import com.profect.tickle.global.rabbitMQ.config.RabbitMQEventConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TicketApplyBatchProcessor { + + private final RabbitTemplate rabbitTemplate; + private final ObjectMapper objectMapper; + private final EventCoreLockService eventCoreLockService; + private final PostActionsProducer postProducer; + + @Scheduled(fixedDelay = 100) + public void batchConsume() { + int maxBatchSize = 1000; + List requests = new ArrayList<>(); + + for (int i = 0; i < maxBatchSize; i++) { + Object raw = rabbitTemplate.receiveAndConvert(RabbitMQEventConfig.EVENT_QUEUE); + if (raw == null) break; + + try { + ApplyRequestDto dto = objectMapper.convertValue(raw, ApplyRequestDto.class); + requests.add(dto); + } catch (Exception e) { + log.error("메시지 역직렬화 실패", e); + } + } + + if (requests.isEmpty()) return; + + log.info("배치 응모 처리 시작 - size: {}", requests.size()); + + for (ApplyRequestDto request : requests) { + try { + EventDecision d = eventCoreLockService.applyCore(request.getEventId(), request.getMemberId()); + + postProducer.sendPointHistory(new PostPointHistoryMessage(request.getMemberId(), d.perPrice())); + + if (d.winner()) { + postProducer.sendReservation(new PostReservationMessage(request.getMemberId(), d.seatId(), d.accrued())); + } + + log.error("memberId={} 처리 완료 (당첨: {})", request.getMemberId(), d.winner()); + } catch (Exception e) { + log.error("배치 처리 중 오류: memberId={}, eventId={}", request.getMemberId(), request.getEventId(), e); + } + } + } +} +*/ diff --git a/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/producer/TicketEventProducer.java b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/producer/TicketEventProducer.java new file mode 100644 index 00000000..068827f4 --- /dev/null +++ b/src/main/java/com/profect/tickle/domain/event/service/rabbitmq/producer/TicketEventProducer.java @@ -0,0 +1,35 @@ +package com.profect.tickle.domain.event.service.rabbitmq.producer; + +import com.profect.tickle.domain.event.entity.Event; +import com.profect.tickle.domain.event.repository.EventRepository; +import com.profect.tickle.domain.event.service.rabbitmq.dto.ApplyRequestDto; +import com.profect.tickle.global.exception.BusinessException; +import com.profect.tickle.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Component; + +import static com.profect.tickle.global.rabbitMQ.config.RabbitMQEventConfig.*; + + +@Slf4j +@Component +@RequiredArgsConstructor +public class TicketEventProducer { + + private final RabbitTemplate rabbitTemplate; + private final EventRepository eventRepository; + + public void sendApplyRequest(Long memberId, Long eventId) { + Event event = eventRepository.findById(eventId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_NOT_FOUND)); + + ApplyRequestDto request = ApplyRequestDto.create(eventId, memberId, event.getGoalPrice(), event.getAccrued(), event.getPerPrice()); + + int shardIndex = (int) (eventId % EVENT_SHARD_COUNT); + String routingKey = EVENT_ROUTING_KEY_PREFIX + shardIndex; + + rabbitTemplate.convertAndSend(EVENT_EXCHANGE, routingKey, request); + } +} \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/domain/event/stream/StreamInitializer.java b/src/main/java/com/profect/tickle/domain/event/service/stream/StreamInitializer.java similarity index 97% rename from src/main/java/com/profect/tickle/domain/event/stream/StreamInitializer.java rename to src/main/java/com/profect/tickle/domain/event/service/stream/StreamInitializer.java index d54f74a5..a317468d 100644 --- a/src/main/java/com/profect/tickle/domain/event/stream/StreamInitializer.java +++ b/src/main/java/com/profect/tickle/domain/event/service/stream/StreamInitializer.java @@ -1,4 +1,4 @@ -package com.profect.tickle.domain.event.stream; +package com.profect.tickle.domain.event.service.stream; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/profect/tickle/domain/event/stream/consumer/TicketEventWorker.java b/src/main/java/com/profect/tickle/domain/event/service/stream/consumer/TicketEventWorker.java similarity index 96% rename from src/main/java/com/profect/tickle/domain/event/stream/consumer/TicketEventWorker.java rename to src/main/java/com/profect/tickle/domain/event/service/stream/consumer/TicketEventWorker.java index 6176717f..6fc389a0 100644 --- a/src/main/java/com/profect/tickle/domain/event/stream/consumer/TicketEventWorker.java +++ b/src/main/java/com/profect/tickle/domain/event/service/stream/consumer/TicketEventWorker.java @@ -1,9 +1,9 @@ // com.profect.tickle.domain.event.stream.consumer.TicketEventWorker -package com.profect.tickle.domain.event.stream.consumer; +package com.profect.tickle.domain.event.service.stream.consumer; import com.profect.tickle.domain.event.dto.EventDecision; -import com.profect.tickle.domain.event.service.event.EventCoreLockService; -import com.profect.tickle.domain.event.service.event.PostActionsService; +import com.profect.tickle.domain.event.service.application.EventCoreLockService; +import com.profect.tickle.domain.event.service.application.PostActionsService; import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,12 +22,12 @@ import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; -import static com.profect.tickle.domain.event.stream.StreamInitializer.GROUP; -import static com.profect.tickle.domain.event.stream.StreamInitializer.STREAM_KEY; +import static com.profect.tickle.domain.event.service.stream.StreamInitializer.GROUP; +import static com.profect.tickle.domain.event.service.stream.StreamInitializer.STREAM_KEY; import static com.profect.tickle.domain.point.entity.PointTarget.EVENT; @Slf4j -@Component +//@Component @RequiredArgsConstructor public class TicketEventWorker { diff --git a/src/main/java/com/profect/tickle/domain/event/stream/dto/EventMessage.java b/src/main/java/com/profect/tickle/domain/event/service/stream/dto/EventMessage.java similarity index 65% rename from src/main/java/com/profect/tickle/domain/event/stream/dto/EventMessage.java rename to src/main/java/com/profect/tickle/domain/event/service/stream/dto/EventMessage.java index 77f13309..255126c3 100644 --- a/src/main/java/com/profect/tickle/domain/event/stream/dto/EventMessage.java +++ b/src/main/java/com/profect/tickle/domain/event/service/stream/dto/EventMessage.java @@ -1,4 +1,4 @@ -package com.profect.tickle.domain.event.stream.dto; +package com.profect.tickle.domain.event.service.stream.dto; import java.io.Serializable; diff --git a/src/main/java/com/profect/tickle/domain/event/stream/producer/EventProducer.java b/src/main/java/com/profect/tickle/domain/event/service/stream/producer/EventProducer.java similarity index 85% rename from src/main/java/com/profect/tickle/domain/event/stream/producer/EventProducer.java rename to src/main/java/com/profect/tickle/domain/event/service/stream/producer/EventProducer.java index 0d174881..26cb9a8c 100644 --- a/src/main/java/com/profect/tickle/domain/event/stream/producer/EventProducer.java +++ b/src/main/java/com/profect/tickle/domain/event/service/stream/producer/EventProducer.java @@ -1,6 +1,6 @@ -package com.profect.tickle.domain.event.stream.producer; +package com.profect.tickle.domain.event.service.stream.producer; -import com.profect.tickle.domain.event.stream.dto.EventMessage; +import com.profect.tickle.domain.event.service.stream.dto.EventMessage; import lombok.RequiredArgsConstructor; import org.redisson.api.RStream; import org.redisson.api.RedissonClient; diff --git a/src/main/java/com/profect/tickle/domain/event/stream/IdempotencyService.java b/src/main/java/com/profect/tickle/domain/event/stream/IdempotencyService.java deleted file mode 100644 index fa1953ab..00000000 --- a/src/main/java/com/profect/tickle/domain/event/stream/IdempotencyService.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.profect.tickle.domain.event.stream; - -import lombok.RequiredArgsConstructor; -import org.redisson.api.RBucket; -import org.redisson.api.RedissonClient; - -import java.util.concurrent.TimeUnit; - -/** 동일 (eventId, memberId) 요청의 중복 처리를 막기 위한 간단한 멱등 키 */ -//@Component -@RequiredArgsConstructor -public class IdempotencyService { - - private final RedissonClient redisson; - - public boolean tryMarkProcessed(Long eventId, Long memberId) { - String key = "idem:ticket:" + eventId + ":" + memberId; - RBucket bucket = redisson.getBucket(key); - // 존재하지 않으면 "1"로 세팅 + TTL 6시간 설정 → true 반환 - return bucket.trySet("1", 6, TimeUnit.HOURS); - } -} \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/domain/member/controller/MyPageController.java b/src/main/java/com/profect/tickle/domain/member/controller/MyPageController.java index a520de6f..d0ab2847 100644 --- a/src/main/java/com/profect/tickle/domain/member/controller/MyPageController.java +++ b/src/main/java/com/profect/tickle/domain/member/controller/MyPageController.java @@ -1,7 +1,7 @@ package com.profect.tickle.domain.member.controller; import com.profect.tickle.domain.event.dto.response.CouponResponseDto; -import com.profect.tickle.domain.event.service.event.EventService; +import com.profect.tickle.domain.event.service.application.EventService; import com.profect.tickle.domain.member.dto.response.MemberResponseDto; import com.profect.tickle.domain.member.service.MemberService; import com.profect.tickle.global.paging.PagingResponse; diff --git a/src/main/java/com/profect/tickle/domain/member/repository/MemberRepository.java b/src/main/java/com/profect/tickle/domain/member/repository/MemberRepository.java index 0f4c1feb..c62c54fe 100644 --- a/src/main/java/com/profect/tickle/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/profect/tickle/domain/member/repository/MemberRepository.java @@ -34,4 +34,7 @@ List tryDeductPointReturning(@Param("memberId") Long memberId, """) int tryDeductPoint(@Param("memberId") Long memberId, @Param("amount") int amount); + + @Query("SELECT m.pointBalance FROM Member m WHERE m.id = :memberId") + Optional findPointById(@Param("memberId") Long memberId); } diff --git a/src/main/java/com/profect/tickle/domain/notification/scheduler/CouponScheduler.java b/src/main/java/com/profect/tickle/domain/notification/scheduler/CouponScheduler.java index cfe05837..d2abe74e 100644 --- a/src/main/java/com/profect/tickle/domain/notification/scheduler/CouponScheduler.java +++ b/src/main/java/com/profect/tickle/domain/notification/scheduler/CouponScheduler.java @@ -1,13 +1,11 @@ package com.profect.tickle.domain.notification.scheduler; import com.profect.tickle.domain.event.dto.response.ExpiringSoonCouponResponseDto; -import com.profect.tickle.domain.event.service.event.CouponService; -import com.profect.tickle.domain.event.service.event.EventService; +import com.profect.tickle.domain.event.service.application.EventService; import com.profect.tickle.domain.notification.event.coupon.event.CouponAlmostExpiredEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.time.Clock; @@ -57,7 +55,7 @@ public void publishExpiringSoonCouponList(long daysAhead) { } // 매일 자정에 실행 (00:00) - @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") + //@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") public void runDaily() { publishExpiringSoonCouponList(1L); } diff --git a/src/main/java/com/profect/tickle/domain/reservation/repository/SeatRepository.java b/src/main/java/com/profect/tickle/domain/reservation/repository/SeatRepository.java index 5d87456a..9ec3120c 100644 --- a/src/main/java/com/profect/tickle/domain/reservation/repository/SeatRepository.java +++ b/src/main/java/com/profect/tickle/domain/reservation/repository/SeatRepository.java @@ -102,7 +102,6 @@ long countReservedSeatsByUserAndPerformance(@Param("memberId") Long memberId, from Seat s where s.id = :seatId """) - Long findPerformanceIdBySeatId(@Param("seatId") Long seatId); @Modifying(clearAutomatically = true, flushAutomatically = true) @@ -133,11 +132,12 @@ List assignSeatEventOnce(@Param("eventId") Long eventId, update Seat s set s.member.id = :memberId where s.id = :seatId -""") + """) int assignPreReservedSeatToMember(@Param("seatId") Long seatId, @Param("memberId") Long memberId); - @Query(value = "select seat_id from seat where event_id = :eventId limit 1", nativeQuery = true) + @Query(value = "select seat_id from seat where event_id = :eventId limit 1", + nativeQuery = true) Long findSeatIdByEvent(@Param("eventId") Long eventId); } \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/domain/reservation/service/ReservationInfoService.java b/src/main/java/com/profect/tickle/domain/reservation/service/ReservationInfoService.java index 405f5312..800381cd 100644 --- a/src/main/java/com/profect/tickle/domain/reservation/service/ReservationInfoService.java +++ b/src/main/java/com/profect/tickle/domain/reservation/service/ReservationInfoService.java @@ -1,7 +1,7 @@ package com.profect.tickle.domain.reservation.service; import com.profect.tickle.domain.event.dto.response.CouponResponseDto; -import com.profect.tickle.domain.event.service.event.CouponService; +import com.profect.tickle.domain.event.service.application.CouponService; import com.profect.tickle.domain.point.service.PointService; import com.profect.tickle.domain.reservation.dto.response.preemption.PreemptedSeatInfo; import com.profect.tickle.domain.reservation.dto.response.reservation.ReservationInfoResponseDto; diff --git a/src/main/java/com/profect/tickle/domain/reservation/service/ReservationService.java b/src/main/java/com/profect/tickle/domain/reservation/service/ReservationService.java index 8d501348..b7a19132 100644 --- a/src/main/java/com/profect/tickle/domain/reservation/service/ReservationService.java +++ b/src/main/java/com/profect/tickle/domain/reservation/service/ReservationService.java @@ -1,6 +1,6 @@ package com.profect.tickle.domain.reservation.service; -import com.profect.tickle.domain.event.service.event.CouponService; +import com.profect.tickle.domain.event.service.application.CouponService; import com.profect.tickle.domain.member.entity.CouponReceived; import com.profect.tickle.domain.member.entity.Member; import com.profect.tickle.domain.member.repository.MemberRepository; @@ -23,7 +23,6 @@ import com.profect.tickle.domain.reservation.repository.SeatRepository; import com.profect.tickle.global.exception.BusinessException; import com.profect.tickle.global.exception.ErrorCode; -import com.profect.tickle.global.security.util.SecurityUtil; import com.profect.tickle.global.status.Status; import com.profect.tickle.global.status.StatusIds; import com.profect.tickle.global.status.service.StatusProvider; diff --git a/src/main/java/com/profect/tickle/global/config/RabbitMQConfig.java b/src/main/java/com/profect/tickle/global/config/RabbitMQChatConfig.java similarity index 72% rename from src/main/java/com/profect/tickle/global/config/RabbitMQConfig.java rename to src/main/java/com/profect/tickle/global/config/RabbitMQChatConfig.java index 8e23f1df..8c84564a 100644 --- a/src/main/java/com/profect/tickle/global/config/RabbitMQConfig.java +++ b/src/main/java/com/profect/tickle/global/config/RabbitMQChatConfig.java @@ -19,7 +19,7 @@ * 4. 서버 장애 시 메시지 복구 */ @Configuration -public class RabbitMQConfig { +public class RabbitMQChatConfig { // ===== 큐 정의 ===== public static final String CHAT_MESSAGE_QUEUE = "chat.message.queue"; @@ -34,41 +34,6 @@ public class RabbitMQConfig { public static final String NOTIFICATION_ROUTING_KEY = "notification"; public static final String FILE_ROUTING_KEY = "file"; - /** - * 메시지 변환기 설정 (JSON) - */ - @Bean - public MessageConverter jsonMessageConverter() { - return new Jackson2JsonMessageConverter(); - } - - /** - * RabbitTemplate 설정 - */ - @Bean - public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) { - RabbitTemplate template = new RabbitTemplate(connectionFactory); - template.setMessageConverter(jsonMessageConverter()); - template.setMandatory(true); // 메시지 전달 실패 시 예외 발생 - return template; - } - - /** - * 리스너 컨테이너 팩토리 설정 - */ - @Bean - public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(ConnectionFactory connectionFactory) { - SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory(); - factory.setConnectionFactory(connectionFactory); - factory.setMessageConverter(jsonMessageConverter()); - factory.setConcurrentConsumers(10); // 동시 컨슈머 수 - factory.setMaxConcurrentConsumers(50); // 최대 동시 컨슈머 수 - factory.setPrefetchCount(10); // 미리 가져올 메시지 수 - factory.setAutoStartup(false); - - return factory; - } - /** * 채팅 익스체인지 생성 (Topic Exchange) */ diff --git a/src/main/java/com/profect/tickle/global/exception/ErrorCode.java b/src/main/java/com/profect/tickle/global/exception/ErrorCode.java index a05feca1..e0cafea7 100644 --- a/src/main/java/com/profect/tickle/global/exception/ErrorCode.java +++ b/src/main/java/com/profect/tickle/global/exception/ErrorCode.java @@ -119,7 +119,8 @@ public enum ErrorCode { // 계약 관련 CONTRACT_NOT_FOUND(HttpStatus.NOT_FOUND, "계약을 찾지 못했습니다."), - CONTRACT_CHARGE_INVALID(HttpStatus.BAD_REQUEST, "유요한 수수료율이 아닙니다."); + CONTRACT_CHARGE_INVALID(HttpStatus.BAD_REQUEST, "유요한 수수료율이 아닙니다."), + EVENT_APPLY_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "이벤트 응모에 실패하였습니다."); private final HttpStatus status; private final String message; } diff --git a/src/main/java/com/profect/tickle/global/rabbitMQ/config/RabbitMQConfig.java b/src/main/java/com/profect/tickle/global/rabbitMQ/config/RabbitMQConfig.java new file mode 100644 index 00000000..f1f04889 --- /dev/null +++ b/src/main/java/com/profect/tickle/global/rabbitMQ/config/RabbitMQConfig.java @@ -0,0 +1,37 @@ +package com.profect.tickle.global.rabbitMQ.config; + +import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RabbitMQConfig { + + @Bean + public MessageConverter jsonMessageConverter() { + return new Jackson2JsonMessageConverter(); + } + + @Bean + public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) { + RabbitTemplate template = new RabbitTemplate(connectionFactory); + template.setReplyTimeout(5000); //5초 + template.setMessageConverter(jsonMessageConverter()); + return template; + } + + @Bean + public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(ConnectionFactory connectionFactory) { + SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory(); + factory.setConnectionFactory(connectionFactory); + factory.setMessageConverter(jsonMessageConverter()); + factory.setConcurrentConsumers(20); // 기본 10 → 20 이상 + factory.setMaxConcurrentConsumers(50); // 최대 + factory.setPrefetchCount(10); + return factory; + } +} \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/global/rabbitMQ/config/RabbitMQEventConfig.java b/src/main/java/com/profect/tickle/global/rabbitMQ/config/RabbitMQEventConfig.java new file mode 100644 index 00000000..581936c8 --- /dev/null +++ b/src/main/java/com/profect/tickle/global/rabbitMQ/config/RabbitMQEventConfig.java @@ -0,0 +1,127 @@ +package com.profect.tickle.global.rabbitMQ.config; + +import org.springframework.amqp.core.*; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.ArrayList; +import java.util.List; + +/** + * 티켓 이벤트용 RabbitMQ 설정 + * - Queue, Exchange, Binding 명시적으로 설정 + */ +@Configuration +public class RabbitMQEventConfig { + + // ─────────────────────────────── + // [1] 티켓 응모 이벤트 + // ─────────────────────────────── + public static final int EVENT_SHARD_COUNT = 8; // 필요에 따라 16, 32로 확장 가능 + public static final String EVENT_ROUTING_KEY_PREFIX = "event.shard."; + public static final String EVENT_QUEUE_PREFIX = "event.shard.queue."; + public static final String EVENT_EXCHANGE = "event.exchange"; + public static final String EVENT_QUEUE = "event.ticket.queue"; + public static final String EVENT_ROUTING_KEY = "event.ticket"; + + // ─────────────────────────────── + // [2] 후속 작업 (PostActions) + // ─────────────────────────────── + public static final String POST_POINT_EXCHANGE = "post.point.exchange"; + public static final String POST_POINT_QUEUE = "post.point.queue"; + public static final String POST_POINT_ROUTING_KEY = "post.point"; + + public static final String POST_RESERVATION_EXCHANGE = "post.reservation.exchange"; + public static final String POST_RESERVATION_QUEUE = "post.reservation.queue"; + public static final String POST_RESERVATION_ROUTING_KEY = "post.reservation"; + + + + + // ─────────────────────────────── + // [Exchange / Queue / Binding] + // ─────────────────────────────── + @Bean + public TopicExchange eventExchange() { + return new TopicExchange(EVENT_EXCHANGE, true, false); + } + + @Bean + public Queue eventQueue() { + return QueueBuilder.durable(EVENT_QUEUE) + .withArgument("x-message-ttl", 300000) + .build(); + } + + @Bean + public Binding eventBinding() { + return BindingBuilder.bind(eventQueue()).to(eventExchange()).with(EVENT_ROUTING_KEY); + } + + // ─────────────────────────────── + // Post: PointHistory + // ─────────────────────────────── + @Bean + public TopicExchange postPointExchange() { + return new TopicExchange(POST_POINT_EXCHANGE, true, false); + } + + @Bean + public Queue postPointQueue() { + return QueueBuilder.durable(POST_POINT_QUEUE) + .withArgument("x-message-ttl", 300000) + .build(); + } + + @Bean + public Binding postPointBinding() { + return BindingBuilder.bind(postPointQueue()) + .to(postPointExchange()) + .with(POST_POINT_ROUTING_KEY); + } + + // ─────────────────────────────── + // Post: Reservation + // ─────────────────────────────── + @Bean + public TopicExchange postReservationExchange() { + return new TopicExchange(POST_RESERVATION_EXCHANGE, true, false); + } + + @Bean + public Queue postReservationQueue() { + return QueueBuilder.durable(POST_RESERVATION_QUEUE) + .withArgument("x-message-ttl", 300000) + .build(); + } + + @Bean + public Binding postReservationBinding() { + return BindingBuilder.bind(postReservationQueue()) + .to(postReservationExchange()) + .with(POST_RESERVATION_ROUTING_KEY); + } + + @Bean + public Declarables eventShardQueues() { + List declarables = new ArrayList<>(); + + TopicExchange exchange = new TopicExchange(EVENT_EXCHANGE, true, false); + declarables.add(exchange); + + for (int i = 0; i < EVENT_SHARD_COUNT; i++) { + String queueName = EVENT_QUEUE_PREFIX + i; + String routingKey = EVENT_ROUTING_KEY_PREFIX + i; + + Queue queue = QueueBuilder.durable(queueName) + .withArgument("x-message-ttl", 300000) + .build(); + Binding binding = BindingBuilder.bind(queue).to(exchange).with(routingKey); + + declarables.add(queue); + declarables.add(binding); + } + + return new Declarables(declarables); + } +} \ No newline at end of file diff --git a/src/main/java/com/profect/tickle/global/redis/config/RedisConfig.java b/src/main/java/com/profect/tickle/global/redis/config/RedisConfig.java index 3e601010..10113906 100644 --- a/src/main/java/com/profect/tickle/global/redis/config/RedisConfig.java +++ b/src/main/java/com/profect/tickle/global/redis/config/RedisConfig.java @@ -24,6 +24,7 @@ import org.springframework.data.redis.core.StreamOperations; import org.springframework.data.redis.core.ValueOperations; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.GenericToStringSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.retry.annotation.EnableRetry; @@ -34,15 +35,12 @@ @EnableRetry public class RedisConfig { - @Value("${spring.data.redis.host}") + @Value("${spring.redis.host}") private String host; - @Value("${spring.data.redis.port}") + @Value("${spring.redis.port}") private int port; - @Value("${spring.data.redis.password}") - private String password; - private static final String REDISSON_HOST_PREFIX = "redis://"; @Bean @@ -53,7 +51,6 @@ public RedissonClient redissonClient() { SingleServerConfig s = config.useSingleServer() .setAddress(REDISSON_HOST_PREFIX + host + ":" + port) - .setPassword(password) .setConnectionMinimumIdleSize(8) .setConnectionPoolSize(32) .setSubscriptionConnectionMinimumIdleSize(2) @@ -76,7 +73,6 @@ public RedisConnectionFactory redisConnectionFactory() { RedisStandaloneConfiguration redisConfig = new RedisStandaloneConfiguration(); redisConfig.setHostName(host); redisConfig.setPort(port); - redisConfig.setPassword(password); // Lettuce Pool 설정 (선택사항) LettucePoolingClientConfiguration clientConfig = LettucePoolingClientConfiguration.builder() @@ -101,19 +97,12 @@ public RedisConnectionFactory redisConnectionFactory() { public RedisTemplate redisTemplate() { RedisTemplate redisTemplate = new RedisTemplate<>(); - // Redis를 연결합니다. redisTemplate.setConnectionFactory(redisConnectionFactory()); - // Key-Value 형태로 직렬화를 수행합니다. redisTemplate.setKeySerializer(new StringRedisSerializer()); - redisTemplate.setValueSerializer(new StringRedisSerializer()); - - // Hash Key-Value 형태로 직렬화를 수행합니다. redisTemplate.setHashKeySerializer(new StringRedisSerializer()); - redisTemplate.setHashValueSerializer(new StringRedisSerializer()); - - // 기본적으로 직렬화를 수행합니다. - redisTemplate.setDefaultSerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericToStringSerializer<>(Object.class)); + redisTemplate.setHashValueSerializer(new GenericToStringSerializer<>(Object.class)); redisTemplate.afterPropertiesSet(); return redisTemplate; @@ -174,17 +163,17 @@ public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { @Bean("streamRedisTemplate") public RedisTemplate streamRedisTemplate() { - RedisTemplate template = new RedisTemplate<>(); - template.setConnectionFactory(redisConnectionFactory()); + RedisTemplate redisTemplate = new RedisTemplate<>(); + + redisTemplate.setConnectionFactory(redisConnectionFactory()); - // Stream용으로는 JSON 직렬화 사용 - template.setKeySerializer(new StringRedisSerializer()); - template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); - template.setHashKeySerializer(new StringRedisSerializer()); - template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericToStringSerializer<>(Object.class)); + redisTemplate.setHashValueSerializer(new GenericToStringSerializer<>(Object.class)); - template.afterPropertiesSet(); - return template; + redisTemplate.afterPropertiesSet(); + return redisTemplate; } @Bean diff --git a/src/main/java/com/profect/tickle/global/redis/service/RedisInitialize.java b/src/main/java/com/profect/tickle/global/redis/service/RedisInitialize.java new file mode 100644 index 00000000..726bca9d --- /dev/null +++ b/src/main/java/com/profect/tickle/global/redis/service/RedisInitialize.java @@ -0,0 +1,20 @@ +package com.profect.tickle.global.redis.service; + +import com.profect.tickle.domain.event.entity.Event; +import com.profect.tickle.global.status.StatusIds; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RedisInitialize { + + private final RedisTemplate redisTemplate; + + public void initializeRedisEvent(Event event) { + String key = "event:" + event.getId(); + redisTemplate.opsForHash().put(key, "target", event.getGoalPrice()); + redisTemplate.opsForHash().put(key, "statusId", StatusIds.Event.IN_PROGRESS.toString()); + } +} diff --git a/src/main/java/com/profect/tickle/global/redis/util/ConcurrencyGuardAspect.java b/src/main/java/com/profect/tickle/global/redis/util/ConcurrencyGuardAspect.java index b5518da3..36cebbf9 100644 --- a/src/main/java/com/profect/tickle/global/redis/util/ConcurrencyGuardAspect.java +++ b/src/main/java/com/profect/tickle/global/redis/util/ConcurrencyGuardAspect.java @@ -1,3 +1,4 @@ +/* package com.profect.tickle.global.redis.util; import com.profect.tickle.global.exception.BusinessException; @@ -49,4 +50,4 @@ private String buildLockName(ProceedingJoinPoint joinPoint, String prefix) { String key = args.length > 0 ? args[0].toString() : "default"; return String.format("lock:%s:%s", prefix, key); } -} \ No newline at end of file +}*/ diff --git a/src/main/resources/mapper/event/EventMapper.xml b/src/main/resources/mapper/event/EventMapper.xml index 26f53f3f..de12ab55 100644 --- a/src/main/resources/mapper/event/EventMapper.xml +++ b/src/main/resources/mapper/event/EventMapper.xml @@ -176,4 +176,15 @@ AND e.status_id IN (4, 5) AND p.performance_end_date <= NOW() + + \ No newline at end of file diff --git a/src/main/resources/scripts/event_decrement.lua b/src/main/resources/scripts/event_decrement.lua new file mode 100644 index 00000000..59b6e9ca --- /dev/null +++ b/src/main/resources/scripts/event_decrement.lua @@ -0,0 +1,15 @@ +-- KEYS[1] : event:{id} +-- ARGV[1] : perPrice +-- ARGV[2] : statusId (COMPLETED) + +local status = redis.call('HGET', KEYS[1], 'status') +if status == 'COMPLETED' then + return -99999 -- 이미 종료된 이벤트 +end + +local target = redis.call('HINCRBY', KEYS[1], 'target', -ARGV[1]) +if target <= 0 then + redis.call('HSET', KEYS[1], 'statusId', ARGV[2]) + redis.call('HSET', KEYS[1], 'status', 'COMPLETED') +end +return target \ No newline at end of file diff --git a/src/test/java/com/profect/tickle/domain/event/controller/EventControllerTest.java b/src/test/java/com/profect/tickle/domain/event/controller/EventControllerTest.java index f0e237b6..b5575777 100644 --- a/src/test/java/com/profect/tickle/domain/event/controller/EventControllerTest.java +++ b/src/test/java/com/profect/tickle/domain/event/controller/EventControllerTest.java @@ -5,8 +5,8 @@ import com.profect.tickle.domain.event.dto.request.CouponCreateRequestDto; import com.profect.tickle.domain.event.dto.response.*; import com.profect.tickle.domain.event.entity.EventType; -import com.profect.tickle.domain.event.service.CouponService; -import com.profect.tickle.domain.event.service.EventService; +import com.profect.tickle.domain.event.service.application.CouponService; +import com.profect.tickle.domain.event.service.application.EventService; import com.profect.tickle.domain.reservation.entity.SeatGrade; import com.profect.tickle.global.exception.BusinessException; import com.profect.tickle.global.exception.ErrorCode; diff --git a/src/test/java/com/profect/tickle/domain/event/service/impl/EventServiceImplTest.java b/src/test/java/com/profect/tickle/domain/event/service/impl/EventServiceImplTest.java index 167b6235..ef20f20c 100644 --- a/src/test/java/com/profect/tickle/domain/event/service/impl/EventServiceImplTest.java +++ b/src/test/java/com/profect/tickle/domain/event/service/impl/EventServiceImplTest.java @@ -10,7 +10,7 @@ import com.profect.tickle.domain.event.entity.Event; import com.profect.tickle.domain.event.repository.CouponRepository; import com.profect.tickle.domain.event.repository.EventRepository; -import com.profect.tickle.domain.event.service.EventService; +import com.profect.tickle.domain.event.service.application.EventService; import com.profect.tickle.domain.member.entity.Member; import com.profect.tickle.domain.member.repository.CouponReceivedRepository; import com.profect.tickle.domain.member.repository.MemberRepository; diff --git a/src/test/java/com/profect/tickle/domain/event/service/rabbitmq/consumer/TicketEventConsumerTest.java b/src/test/java/com/profect/tickle/domain/event/service/rabbitmq/consumer/TicketEventConsumerTest.java new file mode 100644 index 00000000..5dccfa7a --- /dev/null +++ b/src/test/java/com/profect/tickle/domain/event/service/rabbitmq/consumer/TicketEventConsumerTest.java @@ -0,0 +1,90 @@ +package com.profect.tickle.domain.event.service.rabbitmq.consumer; + +import com.profect.config.RedisTestConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.test.context.ActiveProfiles; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +class TicketEventConsumerTest { + + @Autowired + private RedisTemplate redisTemplate; + + private DefaultRedisScript script; + + @BeforeEach + void setUp() throws IOException { + // Lua 스크립트 로드 + try (InputStream is = new ClassPathResource("scripts/event_decrement.lua").getInputStream()) { + String lua = new String(is.readAllBytes(), StandardCharsets.UTF_8); + this.script = new DefaultRedisScript<>(lua, Long.class); + } + + // Redis 초기화 + redisTemplate.getConnectionFactory().getConnection().flushAll(); + + // 초기 상태 세팅 + Map eventData = new HashMap<>(); + eventData.put("target", 100); // 목표금액 100 + eventData.put("status", "IN_PROGRESS"); // 진행중 상태 + eventData.put("statusId", 1); // (임의) + redisTemplate.opsForHash().putAll("event:1", eventData); + } + + @Test + void testLuaAtomicDecrement() { + String key = "event:1"; + Long perPrice = 1L; + String completed = "COMPLETED"; + + long lastResult = 0L; + int completedAt = 0; + + // 1~110명의 유저 요청 시뮬레이션 + for (int i = 1; i <= 110; i++) { + Long result = redisTemplate.execute( + script, + Collections.singletonList(key), + perPrice, + completed + ); + + if (result == -99999) { + System.out.printf("🚫 %d번째 요청 실패 (이벤트 종료됨)%n", i); + continue; + } + + if (result <= 0 && completedAt == 0) { + completedAt = i; + System.out.printf("🎯 %d번째 요청에서 목표 달성! result=%d%n", i, result); + } + + lastResult = result; + } + + // ✅ 검증 + assertEquals(0L, lastResult, "마지막 감소 결과는 0이어야 한다"); + assertEquals(100, completedAt, "정확히 100번째 요청에서 이벤트가 종료되어야 한다"); + + // ✅ 이후 상태 검증 + String status = (String) redisTemplate.opsForHash().get(key, "status"); + assertEquals("COMPLETED", status, "이벤트 상태가 COMPLETED로 변경되어야 한다"); + } +} \ No newline at end of file