diff --git a/backend/src/main/java/com/festago/ticketing/application/TicketQuantityEventListener.java b/backend/src/main/java/com/festago/ticketing/application/TicketSequenceEventListener.java similarity index 76% rename from backend/src/main/java/com/festago/ticketing/application/TicketQuantityEventListener.java rename to backend/src/main/java/com/festago/ticketing/application/TicketSequenceEventListener.java index ac4eaa874..4fd76cdba 100644 --- a/backend/src/main/java/com/festago/ticketing/application/TicketQuantityEventListener.java +++ b/backend/src/main/java/com/festago/ticketing/application/TicketSequenceEventListener.java @@ -3,7 +3,7 @@ import com.festago.ticket.domain.NewTicket; import com.festago.ticket.dto.event.TicketCreatedEvent; import com.festago.ticket.dto.event.TicketDeletedEvent; -import com.festago.ticketing.application.command.TicketQuantityUpdateService; +import com.festago.ticketing.application.command.TicketSequenceUpdateService; import lombok.RequiredArgsConstructor; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @@ -12,21 +12,21 @@ @Component @RequiredArgsConstructor -public class TicketQuantityEventListener { +public class TicketSequenceEventListener { - private final TicketQuantityUpdateService ticketQuantityUpdateService; + private final TicketSequenceUpdateService ticketSequenceUpdateService; @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void ticketCreatedEventHandler(TicketCreatedEvent event) { NewTicket ticket = event.ticket(); - ticketQuantityUpdateService.putOrDeleteTicketQuantity(ticket); + ticketSequenceUpdateService.putOrDeleteTicketSequence(ticket); } @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void ticketDeletedEventHandler(TicketDeletedEvent event) { NewTicket ticket = event.ticket(); - ticketQuantityUpdateService.putOrDeleteTicketQuantity(ticket); + ticketSequenceUpdateService.putOrDeleteTicketSequence(ticket); } } diff --git a/backend/src/main/java/com/festago/ticketing/application/command/QuantityTicketingService.java b/backend/src/main/java/com/festago/ticketing/application/command/SequenceTicketingService.java similarity index 67% rename from backend/src/main/java/com/festago/ticketing/application/command/QuantityTicketingService.java rename to backend/src/main/java/com/festago/ticketing/application/command/SequenceTicketingService.java index 6aa9a4c90..3645fd1f4 100644 --- a/backend/src/main/java/com/festago/ticketing/application/command/QuantityTicketingService.java +++ b/backend/src/main/java/com/festago/ticketing/application/command/SequenceTicketingService.java @@ -5,11 +5,11 @@ import com.festago.common.exception.NotFoundException; import com.festago.common.exception.TooManyRequestException; import com.festago.ticketing.domain.Booker; -import com.festago.ticketing.domain.TicketQuantity; +import com.festago.ticketing.domain.TicketSequence; import com.festago.ticketing.domain.TicketingRateLimiter; import com.festago.ticketing.dto.TicketingResult; import com.festago.ticketing.dto.command.TicketingCommand; -import com.festago.ticketing.repository.TicketQuantityRepository; +import com.festago.ticketing.repository.TicketSequenceRepository; import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -17,36 +17,37 @@ @Service @RequiredArgsConstructor //@Transactional 명시적으로 Transactional 사용하지 않음 -public class QuantityTicketingService { +public class SequenceTicketingService { - private final TicketQuantityRepository ticketQuantityRepository; private final TicketingCommandService ticketingCommandService; + private final TicketSequenceRepository ticketSequenceRepository; private final TicketingRateLimiter ticketingRateLimiter; public TicketingResult ticketing(TicketingCommand command) { - TicketQuantity ticketQuantity = getTicketQuantity(command.ticketId()); + Long ticketId = command.ticketId(); + TicketSequence ticketSequence = getTicketSequence(ticketId); validateFrequentTicketing(command.booker()); + int sequence = ticketSequence.reserve(); try { - ticketQuantity.decreaseQuantity(); - return ticketingCommandService.reserveTicket(command); + return ticketingCommandService.ticketing(command, sequence); } catch (Exception e) { - ticketQuantity.increaseQuantity(); + ticketSequence.cancel(sequence); throw e; } } - private TicketQuantity getTicketQuantity(Long ticketId) { - TicketQuantity ticketQuantity = ticketQuantityRepository.findByTicketId(ticketId) - .orElseThrow(() -> new NotFoundException(ErrorCode.TICKET_NOT_FOUND)); - if (ticketQuantity.isSoldOut()) { - throw new BadRequestException(ErrorCode.TICKET_SOLD_OUT); - } - return ticketQuantity; - } - private void validateFrequentTicketing(Booker booker) { if (ticketingRateLimiter.isFrequentTicketing(booker, 5, TimeUnit.SECONDS)) { throw new TooManyRequestException(); } } + + private TicketSequence getTicketSequence(Long ticketId) { + TicketSequence ticketSequence = ticketSequenceRepository.findByTicketId(ticketId) + .orElseThrow(() -> new NotFoundException(ErrorCode.TICKET_NOT_FOUND)); + if (ticketSequence.isSoldOut()) { + throw new BadRequestException(ErrorCode.TICKET_SOLD_OUT); + } + return ticketSequence; + } } diff --git a/backend/src/main/java/com/festago/ticketing/application/command/TicketQuantityUpdateService.java b/backend/src/main/java/com/festago/ticketing/application/command/TicketQuantityUpdateService.java deleted file mode 100644 index 2711898fa..000000000 --- a/backend/src/main/java/com/festago/ticketing/application/command/TicketQuantityUpdateService.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.festago.ticketing.application.command; - -import com.festago.ticket.domain.NewTicket; -import com.festago.ticketing.repository.TicketQuantityRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class TicketQuantityUpdateService { - - private final TicketQuantityRepository ticketQuantityRepository; - - public void putOrDeleteTicketQuantity(NewTicket ticket) { - if (ticket.isEmptyAmount()) { - ticketQuantityRepository.delete(ticket); - } else { - ticketQuantityRepository.put(ticket); - } - } -} diff --git a/backend/src/main/java/com/festago/ticketing/application/command/TicketSequenceUpdateService.java b/backend/src/main/java/com/festago/ticketing/application/command/TicketSequenceUpdateService.java new file mode 100644 index 000000000..ff356efb9 --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/application/command/TicketSequenceUpdateService.java @@ -0,0 +1,21 @@ +package com.festago.ticketing.application.command; + +import com.festago.ticket.domain.NewTicket; +import com.festago.ticketing.repository.TicketSequenceRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class TicketSequenceUpdateService { + + private final TicketSequenceRepository ticketSequenceRepository; + + public void putOrDeleteTicketSequence(NewTicket ticket) { + if (ticket.isEmptyAmount()) { + ticketSequenceRepository.delete(ticket); + } else { + ticketSequenceRepository.put(ticket); + } + } +} diff --git a/backend/src/main/java/com/festago/ticketing/application/command/TicketingCommandService.java b/backend/src/main/java/com/festago/ticketing/application/command/TicketingCommandService.java index 6d15668dd..4ccede815 100644 --- a/backend/src/main/java/com/festago/ticketing/application/command/TicketingCommandService.java +++ b/backend/src/main/java/com/festago/ticketing/application/command/TicketingCommandService.java @@ -7,7 +7,6 @@ import com.festago.ticket.repository.NewTicketDao; import com.festago.ticketing.domain.Booker; import com.festago.ticketing.domain.ReserveTicket; -import com.festago.ticketing.domain.TicketingSequenceGenerator; import com.festago.ticketing.domain.validator.TicketingValidator; import com.festago.ticketing.dto.TicketingResult; import com.festago.ticketing.dto.command.TicketingCommand; @@ -26,11 +25,10 @@ public class TicketingCommandService { private final NewTicketDao ticketDao; private final ReserveTicketRepository reserveTicketRepository; - private final TicketingSequenceGenerator sequenceGenerator; private final List validators; private final Clock clock; - public TicketingResult reserveTicket(TicketingCommand command) { + public TicketingResult ticketing(TicketingCommand command, int sequence) { Long ticketId = command.ticketId(); NewTicketType ticketType = command.ticketType(); NewTicket ticket = ticketDao.findByIdWithTicketTypeAndFetch(ticketId, ticketType); @@ -38,9 +36,8 @@ public TicketingResult reserveTicket(TicketingCommand command) { ticket.validateReserve(booker, LocalDateTime.now(clock)); validators.forEach(validator -> validator.validate(ticket, booker)); validate(ticket, booker); - int sequence = sequenceGenerator.generate(ticketId); ReserveTicket reserveTicket = ticket.reserve(booker, sequence); - reserveTicketRepository.save(ticket.reserve(booker, sequence)); + reserveTicketRepository.save(reserveTicket); return new TicketingResult(reserveTicket.getTicketId()); } diff --git a/backend/src/main/java/com/festago/ticketing/domain/TicketQuantity.java b/backend/src/main/java/com/festago/ticketing/domain/TicketQuantity.java deleted file mode 100644 index 474cef66c..000000000 --- a/backend/src/main/java/com/festago/ticketing/domain/TicketQuantity.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.festago.ticketing.domain; - -import com.festago.common.exception.BadRequestException; - -/** - * 티켓의 재고를 관리하는 도메인
해당 도메인을 구현하는 구현체는 반드시 원자적인 연산을 사용해야 한다.
- */ -public interface TicketQuantity { - - /** - * 티켓의 매진 여부를 반환한다. - * - * @return 티켓이 매진이면 true, 매진이 아니면 false - */ - boolean isSoldOut(); - - /** - * 티켓의 재고를 하나 감소시킨다.
해당 메서드의 연산은 atomic 해야 한다.
감소 시킨 뒤 값이 음수인 경우에는 매진이 된 상태에 요청이 들어온 것이므로, 예외를 던져야 한다. - *
- * - * @throws BadRequestException 감소 시킨 뒤 값이 음수이면 - */ - void decreaseQuantity() throws BadRequestException; - - /** - * 티켓의 재고를 하나 증가시킨다.
해당 메서드의 연산은 atomic 해야 한다.
- */ - void increaseQuantity(); - - /** - * 티켓의 남은 재고를 반환한다.
- * - * @return 티켓의 남은 재고 - */ - int getQuantity(); -} diff --git a/backend/src/main/java/com/festago/ticketing/domain/TicketSequence.java b/backend/src/main/java/com/festago/ticketing/domain/TicketSequence.java new file mode 100644 index 000000000..e9e14529a --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/domain/TicketSequence.java @@ -0,0 +1,38 @@ +package com.festago.ticketing.domain; + +import com.festago.common.exception.BadRequestException; + +/** + * 티켓의 재고와 순서를 관리하는 도메인
해당 도메인을 구현하는 구현체는 반드시 원자적인 연산을 사용해야 한다.
+ */ +public interface TicketSequence { + + /** + * 티켓의 매진 여부를 반환한다. + * + * @return 티켓이 매진이면 true, 매진이 아니면 false + */ + boolean isSoldOut(); + + /** + * 티켓의 재고를 하나 감소시키고 순서를 반환한다.
해당 메서드의 연산은 atomic 해야 한다.
티켓의 재고가 비어있을 때 해당 메서드를 호출하면 BadRequestException을 + * 던져야 한다.
+ * + * @throws BadRequestException 티켓의 재고가 비어있으면 + */ + int reserve() throws BadRequestException; + + /** + * 티켓의 재고를 하나 증가시키고 인자로 들어온 순서를 다시 보관한다.
해당 메서드의 연산은 atomic 해야 한다.
+ * + * @param sequence 티켓의 순서 + */ + void cancel(int sequence); + + /** + * 티켓의 남은 재고를 반환한다.
+ * + * @return 티켓의 남은 재고 + */ + int getQuantity(); +} diff --git a/backend/src/main/java/com/festago/ticketing/domain/TicketingSequenceGenerator.java b/backend/src/main/java/com/festago/ticketing/domain/TicketingSequenceGenerator.java deleted file mode 100644 index 226ec5d1c..000000000 --- a/backend/src/main/java/com/festago/ticketing/domain/TicketingSequenceGenerator.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.festago.ticketing.domain; - -public interface TicketingSequenceGenerator { - - int generate(Long ticketId); -} diff --git a/backend/src/main/java/com/festago/ticketing/infrastructure/RedisTicketingSequenceEventListener.java b/backend/src/main/java/com/festago/ticketing/infrastructure/RedisTicketingSequenceEventListener.java deleted file mode 100644 index fad88d1ac..000000000 --- a/backend/src/main/java/com/festago/ticketing/infrastructure/RedisTicketingSequenceEventListener.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.festago.ticketing.infrastructure; - -import com.festago.ticket.domain.NewTicket; -import com.festago.ticket.dto.event.TicketCreatedEvent; -import com.festago.ticket.dto.event.TicketDeletedEvent; -import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -@Component -@RequiredArgsConstructor -public class RedisTicketingSequenceEventListener { - - private final RedisTicketingSequenceGenerator redisTicketingSequenceGenerator; - - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void ticketCreatedEventHandler(TicketCreatedEvent event) { - NewTicket ticket = event.ticket(); - redisTicketingSequenceGenerator.setUp(ticket.getId(), ticket.getTicketingEndTime()); - } - - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void ticketDeletedEventHandler(TicketDeletedEvent event) { - NewTicket ticket = event.ticket(); - redisTicketingSequenceGenerator.delete(ticket.getId()); - } -} diff --git a/backend/src/main/java/com/festago/ticketing/infrastructure/RedisTicketingSequenceGenerator.java b/backend/src/main/java/com/festago/ticketing/infrastructure/RedisTicketingSequenceGenerator.java deleted file mode 100644 index d99df993c..000000000 --- a/backend/src/main/java/com/festago/ticketing/infrastructure/RedisTicketingSequenceGenerator.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.festago.ticketing.infrastructure; - -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.InternalServerException; -import com.festago.ticketing.domain.TicketingSequenceGenerator; -import java.time.Clock; -import java.time.Duration; -import java.time.LocalDateTime; -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class RedisTicketingSequenceGenerator implements TicketingSequenceGenerator { - - private static final String KEY_PREFIX = "ticketing_seq_"; - private final RedisTemplate redisTemplate; - private final Clock clock; - - /** - * TTL을 설정하기 위한 메서드
RedisTicketingSequenceEventListener에서 호출하도록 설계했으니, 특별한 이유가 아니면 직접 호출하는 것을 금지함
- * - * @param ticketId TTL을 설정할 티켓의 식별자 - * @param invalidateAt 만료될 시간 - */ - protected void setUp(Long ticketId, LocalDateTime invalidateAt) { - LocalDateTime now = LocalDateTime.now(clock); - Duration ttl = Duration.between(now, invalidateAt); - redisTemplate.opsForValue().set(KEY_PREFIX + ticketId, "0", ttl); - } - - /** - * 레디스에 저장된 시퀀스를 삭제하기 위한 메서드
RedisTicketingSequenceEventListener에서 호출하도록 설계했으니, 특별한 이유가 아니면 직접 호출하는 것을 금지함 - *
- * - * @param ticketId 삭제할 티켓의 식별자 - */ - protected void delete(Long ticketId) { - redisTemplate.delete(KEY_PREFIX + ticketId); - } - - @Override - public int generate(Long ticketId) { - Long sequence = redisTemplate.opsForValue().increment(KEY_PREFIX + ticketId); - if (sequence == null) { - throw new InternalServerException(ErrorCode.REDIS_ERROR); - } - return sequence.intValue(); - } -} diff --git a/backend/src/main/java/com/festago/ticketing/infrastructure/quantity/RedisTicketQuantity.java b/backend/src/main/java/com/festago/ticketing/infrastructure/quantity/RedisTicketQuantity.java deleted file mode 100644 index 1733451e0..000000000 --- a/backend/src/main/java/com/festago/ticketing/infrastructure/quantity/RedisTicketQuantity.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.festago.ticketing.infrastructure.quantity; - -import com.festago.common.exception.BadRequestException; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.InternalServerException; -import com.festago.common.exception.NotFoundException; -import com.festago.ticketing.domain.TicketQuantity; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.RedisTemplate; - -@Slf4j -@RequiredArgsConstructor -public class RedisTicketQuantity implements TicketQuantity { - - private final String key; - private final RedisTemplate redisTemplate; - - @Override - public boolean isSoldOut() { - String quantity = redisTemplate.opsForValue().get(key); - if (quantity == null) { - log.warn("존재하지 않는 티켓에 대한 요청이 발생했습니다. key={}", key); - throw new NotFoundException(ErrorCode.TICKET_NOT_FOUND); - } - return Integer.parseInt(quantity) <= 0; - } - - @Override - public void decreaseQuantity() { - Long quantity = redisTemplate.opsForValue().decrement(key); - if (quantity == null) { - throw new InternalServerException(ErrorCode.REDIS_ERROR); - } - if (quantity < 0) { - throw new BadRequestException(ErrorCode.TICKET_SOLD_OUT); - } - } - - @Override - public void increaseQuantity() { - Long quantity = redisTemplate.opsForValue().increment(key); - if (quantity == null) { - throw new InternalServerException(ErrorCode.REDIS_ERROR); - } - } - - @Override - public int getQuantity() { - String quantity = redisTemplate.opsForValue().get(key); - if (quantity == null) { - throw new NotFoundException(ErrorCode.TICKET_NOT_FOUND); - } - return Integer.parseInt(quantity); - } -} diff --git a/backend/src/main/java/com/festago/ticketing/infrastructure/quantity/RedisTicketQuantityRepository.java b/backend/src/main/java/com/festago/ticketing/infrastructure/quantity/RedisTicketQuantityRepository.java deleted file mode 100644 index 8e7d76b6f..000000000 --- a/backend/src/main/java/com/festago/ticketing/infrastructure/quantity/RedisTicketQuantityRepository.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.festago.ticketing.infrastructure.quantity; - -import com.festago.ticket.domain.NewTicket; -import com.festago.ticketing.domain.TicketQuantity; -import com.festago.ticketing.repository.TicketQuantityRepository; -import java.time.Clock; -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Repository; - -@Repository -@RequiredArgsConstructor -public class RedisTicketQuantityRepository implements TicketQuantityRepository { - - private static final String KEY_PREFIX = "ticket_quantity_"; - private final RedisTemplate redisTemplate; - private final Clock clock; - - @Override - public TicketQuantity put(NewTicket ticket) { - String key = KEY_PREFIX + ticket.getId(); - int quantity = ticket.getAmount(); - LocalDateTime now = LocalDateTime.now(clock); - Duration ttl = Duration.between(now, ticket.getTicketingEndTime()); - redisTemplate.opsForValue().set(key, String.valueOf(quantity), ttl); - return new RedisTicketQuantity(key, redisTemplate); - } - - @Override - public Optional findByTicketId(Long ticketId) { - return Optional.of(new RedisTicketQuantity(KEY_PREFIX + ticketId, redisTemplate)); - } - - @Override - public void delete(NewTicket ticket) { - redisTemplate.delete(KEY_PREFIX + ticket.getId()); - } -} diff --git a/backend/src/main/java/com/festago/ticketing/infrastructure/sequence/RedisTicketSequence.java b/backend/src/main/java/com/festago/ticketing/infrastructure/sequence/RedisTicketSequence.java new file mode 100644 index 000000000..6d8dabe6c --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/infrastructure/sequence/RedisTicketSequence.java @@ -0,0 +1,47 @@ +package com.festago.ticketing.infrastructure.sequence; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import com.festago.ticketing.domain.TicketSequence; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; + +@RequiredArgsConstructor +public class RedisTicketSequence implements TicketSequence { + + private final String key; + private final RedisTemplate redisTemplate; + + @Override + public boolean isSoldOut() { + Long size = redisTemplate.opsForList().size(key); + if (size == null) { + throw new InternalServerException(ErrorCode.REDIS_ERROR); + } + return size <= 0; + } + + @Override + public int reserve() { + String sequence = redisTemplate.opsForList().rightPop(key); + if (sequence == null) { + throw new BadRequestException(ErrorCode.TICKET_SOLD_OUT); + } + return Integer.parseInt(sequence); + } + + @Override + public void cancel(int sequence) { + redisTemplate.opsForList().leftPush(key, String.valueOf(sequence)); + } + + @Override + public int getQuantity() { + Long size = redisTemplate.opsForList().size(key); + if (size == null) { + throw new InternalServerException(ErrorCode.REDIS_ERROR); + } + return size.intValue(); + } +} diff --git a/backend/src/main/java/com/festago/ticketing/infrastructure/sequence/RedisTicketSequenceRepository.java b/backend/src/main/java/com/festago/ticketing/infrastructure/sequence/RedisTicketSequenceRepository.java new file mode 100644 index 000000000..db05fb895 --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/infrastructure/sequence/RedisTicketSequenceRepository.java @@ -0,0 +1,48 @@ +package com.festago.ticketing.infrastructure.sequence; + +import com.festago.ticket.domain.NewTicket; +import com.festago.ticketing.domain.TicketSequence; +import com.festago.ticketing.repository.TicketSequenceRepository; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.stream.IntStream; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class RedisTicketSequenceRepository implements TicketSequenceRepository { + + private static final String KEY_PREFIX = "ticketing_seq_queue_"; + private final RedisTemplate redisTemplate; + private final Clock clock; + + @Override + public TicketSequence put(NewTicket ticket) { + Long ticketId = ticket.getId(); + String key = KEY_PREFIX + ticketId; + redisTemplate.delete(key); + List sequences = IntStream.rangeClosed(1, ticket.getAmount()) + .mapToObj(String::valueOf) + .toList(); + redisTemplate.opsForList().leftPushAll(key, sequences); + LocalDateTime now = LocalDateTime.now(clock); + Duration duration = Duration.between(now, ticket.getTicketingEndTime()); + redisTemplate.expire(key, duration); + return new RedisTicketSequence(key, redisTemplate); + } + + @Override + public Optional findByTicketId(Long ticketId) { + return Optional.of(new RedisTicketSequence(KEY_PREFIX + ticketId, redisTemplate)); + } + + @Override + public void delete(NewTicket ticket) { + redisTemplate.delete(KEY_PREFIX + ticket.getId()); + } +} diff --git a/backend/src/main/java/com/festago/ticketing/repository/TicketQuantityRepository.java b/backend/src/main/java/com/festago/ticketing/repository/TicketQuantityRepository.java deleted file mode 100644 index 15db977f4..000000000 --- a/backend/src/main/java/com/festago/ticketing/repository/TicketQuantityRepository.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.festago.ticketing.repository; - -import com.festago.ticket.domain.NewTicket; -import com.festago.ticketing.domain.TicketQuantity; -import java.util.Optional; - -/** - * TicketQuantity를 저장, 삭제, 조회하는 Repository - */ -public interface TicketQuantityRepository { - - /** - * Ticket에 대한 정보를 가진 TicketQuantity를 만들어 저장한다.
- * 기존 TicketQuantity가 저장되어 있으면 덮어쓴다.
- * - * @param ticket 저장할 TicketQuantity에 대한 Ticket 엔티티 - * @return TicketQuantity - */ - TicketQuantity put(NewTicket ticket); - - /** - * Ticket의 식별자에 대한 TicketQuantity를 반환한다.
- * - * @param ticketId Ticket의 식별자 - * @return TicketQuantity - */ - Optional findByTicketId(Long ticketId); - - /** - * Ticket에 대한 TicketQuantity를 삭제한다.
- * - * @param ticket 삭제할 TicketQuantity에 대한 Ticket 엔티티 - */ - void delete(NewTicket ticket); -} diff --git a/backend/src/main/java/com/festago/ticketing/repository/TicketSequenceRepository.java b/backend/src/main/java/com/festago/ticketing/repository/TicketSequenceRepository.java new file mode 100644 index 000000000..1b0ccf95e --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/repository/TicketSequenceRepository.java @@ -0,0 +1,34 @@ +package com.festago.ticketing.repository; + +import com.festago.ticket.domain.NewTicket; +import com.festago.ticketing.domain.TicketSequence; +import java.util.Optional; + +/** + * TicketSequence를 저장, 삭제, 조회하는 Repository + */ +public interface TicketSequenceRepository { + + /** + * Ticket에 대한 정보를 가진 TicketSequence를 만들어 저장한다.
기존 TicketSequence가 저장되어 있으면 덮어쓴다.
+ * + * @param ticket 저장할 TicketSequence에 대한 Ticket 엔티티 + * @return TicketSequence + */ + TicketSequence put(NewTicket ticket); + + /** + * Ticket의 식별자에 대한 TicketSequence를 반환한다.
+ * + * @param ticketId Ticket의 식별자 + * @return TicketSequence + */ + Optional findByTicketId(Long ticketId); + + /** + * Ticket에 대한 TicketSequence를 삭제한다.
+ * + * @param ticket 삭제할 TicketSequence에 대한 Ticket 엔티티 + */ + void delete(NewTicket ticket); +} diff --git a/backend/src/test/java/com/festago/ticketing/application/command/QuantityTicketingServiceTest.java b/backend/src/test/java/com/festago/ticketing/application/command/SequenceTicketingServiceTest.java similarity index 71% rename from backend/src/test/java/com/festago/ticketing/application/command/QuantityTicketingServiceTest.java rename to backend/src/test/java/com/festago/ticketing/application/command/SequenceTicketingServiceTest.java index c3321f7c4..31abaff06 100644 --- a/backend/src/test/java/com/festago/ticketing/application/command/QuantityTicketingServiceTest.java +++ b/backend/src/test/java/com/festago/ticketing/application/command/SequenceTicketingServiceTest.java @@ -1,16 +1,17 @@ package com.festago.ticketing.application.command; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.any; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.mock; import com.festago.common.infrastructure.FakeTicketingRateLimiter; import com.festago.ticket.domain.FakeTicket; -import com.festago.ticketing.domain.TicketQuantity; +import com.festago.ticketing.domain.TicketSequence; import com.festago.ticketing.dto.command.TicketingCommand; -import com.festago.ticketing.repository.MemoryTicketQuantityRepository; -import com.festago.ticketing.repository.TicketQuantityRepository; +import com.festago.ticketing.repository.MemoryTicketSequenceRepository; +import com.festago.ticketing.repository.TicketSequenceRepository; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; @@ -25,23 +26,22 @@ @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) @SuppressWarnings("NonAsciiCharacters") -class QuantityTicketingServiceTest { +class SequenceTicketingServiceTest { - QuantityTicketingService quantityTicketingService; - - TicketQuantityRepository ticketQuantityRepository; + TicketSequenceRepository ticketSequenceRepository; @BeforeEach void setUp() { - ticketQuantityRepository = new MemoryTicketQuantityRepository(); + ticketSequenceRepository = new MemoryTicketSequenceRepository(); } @Nested - class reserveTicket { + class ticketing { Long ticketId = 1L; + TicketSequence ticketSequence; AtomicLong reserveCount; - TicketQuantity ticketQuantity; + SequenceTicketingService sequenceTicketingService; TicketingCommandService ticketingCommandService; ExecutorService executorService = Executors.newFixedThreadPool(8); TicketingCommand command = TicketingCommand.builder() @@ -50,11 +50,11 @@ class reserveTicket { @BeforeEach void setUp() { - reserveCount = new AtomicLong(); ticketingCommandService = mock(); - quantityTicketingService = new QuantityTicketingService( - ticketQuantityRepository, + reserveCount = new AtomicLong(); + sequenceTicketingService = new SequenceTicketingService( ticketingCommandService, + ticketSequenceRepository, new FakeTicketingRateLimiter(false) ); } @@ -64,8 +64,8 @@ void setUp() { // given int ticketAmount = 50; long tryCount = 100; - ticketQuantity = ticketQuantityRepository.put(new FakeTicket(ticketId, ticketAmount)); - given(ticketingCommandService.reserveTicket(any())).willAnswer(invoke -> { + ticketSequence = ticketSequenceRepository.put(new FakeTicket(ticketId, ticketAmount)); + given(ticketingCommandService.ticketing(any(), anyInt())).willAnswer(invoke -> { reserveCount.incrementAndGet(); return null; }); @@ -73,13 +73,13 @@ void setUp() { // when List> futures = LongStream.rangeClosed(1, tryCount) .mapToObj(i -> CompletableFuture.runAsync(() -> { - quantityTicketingService.ticketing(command); + sequenceTicketingService.ticketing(command); }, executorService).exceptionally(throwable -> null)) .toList(); futures.forEach(CompletableFuture::join); // then - assertThat(ticketQuantity.getQuantity()).isZero(); + assertThat(ticketSequence.getQuantity()).isZero(); assertThat(reserveCount).hasValue(ticketAmount); } @@ -89,10 +89,10 @@ void setUp() { int ticketAmount = 50; long tryCount = 100; AtomicLong counter = new AtomicLong(); - ticketQuantity = ticketQuantityRepository.put(new FakeTicket(ticketId, ticketAmount)); - given(ticketingCommandService.reserveTicket(any())).willAnswer(invoke -> { + ticketSequence = ticketSequenceRepository.put(new FakeTicket(ticketId, ticketAmount)); + given(ticketingCommandService.ticketing(any(), anyInt())).willAnswer(invoke -> { long count = counter.incrementAndGet(); - if (count <= 25 && count % 5 == 0) { // 5번 예외 발생 + if (count <= ticketAmount / 2 && count % 5 == 0) { throw new IllegalArgumentException(); } reserveCount.incrementAndGet(); @@ -102,13 +102,13 @@ void setUp() { // when List> futures = LongStream.rangeClosed(1, tryCount) .mapToObj(i -> CompletableFuture.runAsync(() -> { - quantityTicketingService.ticketing(command); + sequenceTicketingService.ticketing(command); }, executorService).exceptionally(throwable -> null)) .toList(); futures.forEach(CompletableFuture::join); // then - assertThat(ticketQuantity.getQuantity()).isZero(); + assertThat(ticketSequence.getQuantity()).isZero(); assertThat(reserveCount).hasValue(ticketAmount); } } diff --git a/backend/src/test/java/com/festago/ticketing/application/command/TicketingCommandServiceTest.java b/backend/src/test/java/com/festago/ticketing/application/command/TicketingCommandServiceTest.java index 637baefb0..e757d2c9e 100644 --- a/backend/src/test/java/com/festago/ticketing/application/command/TicketingCommandServiceTest.java +++ b/backend/src/test/java/com/festago/ticketing/application/command/TicketingCommandServiceTest.java @@ -23,7 +23,6 @@ import com.festago.ticket.repository.StageTicketRepository; import com.festago.ticketing.domain.Booker; import com.festago.ticketing.dto.command.TicketingCommand; -import com.festago.ticketing.infrastructure.MemoryTicketingSequenceGenerator; import com.festago.ticketing.repository.MemoryReserveTicketRepository; import com.festago.ticketing.repository.ReserveTicketRepository; import java.time.Clock; @@ -55,18 +54,18 @@ void setUp() { ticketingCommandService = new TicketingCommandService( new NewTicketDao(stageTicketRepository), reserveTicketRepository, - new MemoryTicketingSequenceGenerator(), Collections.emptyList(), clock ); } @Nested - class reserveTicket { + class ticketing { StageTicket stageTicket; LocalDateTime 무대_시작_시간 = LocalDateTime.parse("2077-06-30T18:00:00"); LocalDateTime 티켓_오픈_시간 = LocalDateTime.parse("2077-06-23T18:00:00"); + int sequence = 1; @BeforeEach void setUp() { @@ -101,7 +100,7 @@ void setUp() { .build(); // when & then - assertThatThrownBy(() -> ticketingCommandService.reserveTicket(command)) + assertThatThrownBy(() -> ticketingCommandService.ticketing(command, sequence)) .isInstanceOf(NotFoundException.class) .hasMessage(ErrorCode.TICKET_NOT_FOUND.getMessage()); } @@ -114,10 +113,10 @@ void setUp() { .booker(new Booker(1L, 1L)) .ticketType(NewTicketType.STAGE) .build(); - ticketingCommandService.reserveTicket(command); + ticketingCommandService.ticketing(command, sequence); // when & then - assertThatThrownBy(() -> ticketingCommandService.reserveTicket(command)) + assertThatThrownBy(() -> ticketingCommandService.ticketing(command, sequence)) .isInstanceOf(BadRequestException.class) .hasMessage(ErrorCode.RESERVE_TICKET_OVER_AMOUNT.getMessage()); } @@ -132,7 +131,7 @@ void setUp() { .build(); // when - ticketingCommandService.reserveTicket(command); + ticketingCommandService.ticketing(command, sequence); // then assertThat(reserveTicketRepository.countByMemberIdAndTicketId(1L, stageTicket.getId())) diff --git a/backend/src/test/java/com/festago/ticketing/infrastructure/MemoryTicketQuantity.java b/backend/src/test/java/com/festago/ticketing/infrastructure/MemoryTicketQuantity.java deleted file mode 100644 index 3d5e84db5..000000000 --- a/backend/src/test/java/com/festago/ticketing/infrastructure/MemoryTicketQuantity.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.festago.ticketing.infrastructure; - -import com.festago.common.exception.BadRequestException; -import com.festago.common.exception.ErrorCode; -import com.festago.ticketing.domain.TicketQuantity; -import java.util.concurrent.atomic.AtomicInteger; - -public class MemoryTicketQuantity implements TicketQuantity { - - private final AtomicInteger quantity; - - public MemoryTicketQuantity(int quantity) { - this.quantity = new AtomicInteger(quantity); - } - - @Override - public boolean isSoldOut() { - return quantity.get() <= 0; - } - - @Override - public void decreaseQuantity() { - int remainAmount = quantity.decrementAndGet(); - if (remainAmount < 0) { // 티켓 재고 음수 방지 - throw new BadRequestException(ErrorCode.TICKET_SOLD_OUT); - } - } - - @Override - public void increaseQuantity() { - quantity.incrementAndGet(); - } - - @Override - public int getQuantity() { - return quantity.get(); - } -} diff --git a/backend/src/test/java/com/festago/ticketing/infrastructure/MemoryTicketingSequenceGenerator.java b/backend/src/test/java/com/festago/ticketing/infrastructure/MemoryTicketingSequenceGenerator.java deleted file mode 100644 index 1bb91284f..000000000 --- a/backend/src/test/java/com/festago/ticketing/infrastructure/MemoryTicketingSequenceGenerator.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.festago.ticketing.infrastructure; - -import com.festago.ticketing.domain.TicketingSequenceGenerator; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; - -public class MemoryTicketingSequenceGenerator implements TicketingSequenceGenerator { - - private final Map memory = new ConcurrentHashMap<>(); - - @Override - public int generate(Long ticketId) { - AtomicInteger sequence = memory.computeIfAbsent(ticketId, ignore -> new AtomicInteger()); - return sequence.incrementAndGet(); - } -} diff --git a/backend/src/test/java/com/festago/ticketing/infrastructure/sequence/MemoryTicketSequence.java b/backend/src/test/java/com/festago/ticketing/infrastructure/sequence/MemoryTicketSequence.java new file mode 100644 index 000000000..a04fc974a --- /dev/null +++ b/backend/src/test/java/com/festago/ticketing/infrastructure/sequence/MemoryTicketSequence.java @@ -0,0 +1,46 @@ +package com.festago.ticketing.infrastructure.sequence; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.ticketing.domain.TicketSequence; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.stream.IntStream; + +public class MemoryTicketSequence implements TicketSequence { + + private final Queue queue; + + public MemoryTicketSequence(int quantity) { + this.queue = new ArrayBlockingQueue<>(quantity); + List sequences = IntStream.rangeClosed(1, quantity) + .boxed() + .toList(); + queue.addAll(sequences); + } + + @Override + public boolean isSoldOut() { + return queue.isEmpty(); + } + + @Override + public int reserve() { + Integer sequence = queue.poll(); + if (sequence == null) { + throw new BadRequestException(ErrorCode.TICKET_SOLD_OUT); + } + return sequence; + } + + @Override + public void cancel(int sequence) { + queue.offer(sequence); + } + + @Override + public int getQuantity() { + return queue.size(); + } +} diff --git a/backend/src/test/java/com/festago/ticketing/repository/MemoryTicketQuantityRepository.java b/backend/src/test/java/com/festago/ticketing/repository/MemoryTicketQuantityRepository.java deleted file mode 100644 index 0ff7cb127..000000000 --- a/backend/src/test/java/com/festago/ticketing/repository/MemoryTicketQuantityRepository.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.festago.ticketing.repository; - -import com.festago.ticket.domain.NewTicket; -import com.festago.ticketing.domain.TicketQuantity; -import com.festago.ticketing.infrastructure.MemoryTicketQuantity; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; - -public class MemoryTicketQuantityRepository implements TicketQuantityRepository { - - private final ConcurrentHashMap memory = new ConcurrentHashMap<>(); - - @Override - public TicketQuantity put(NewTicket ticket) { - TicketQuantity ticketQuantity = new MemoryTicketQuantity(ticket.getAmount()); - memory.put(ticket.getId(), ticketQuantity); - return ticketQuantity; - } - - @Override - public Optional findByTicketId(Long ticketId) { - return Optional.ofNullable(memory.get(ticketId)); - } - - @Override - public void delete(NewTicket ticket) { - memory.remove(ticket.getId()); - } -} diff --git a/backend/src/test/java/com/festago/ticketing/repository/MemoryTicketSequenceRepository.java b/backend/src/test/java/com/festago/ticketing/repository/MemoryTicketSequenceRepository.java new file mode 100644 index 000000000..36a83632e --- /dev/null +++ b/backend/src/test/java/com/festago/ticketing/repository/MemoryTicketSequenceRepository.java @@ -0,0 +1,30 @@ +package com.festago.ticketing.repository; + +import com.festago.ticket.domain.NewTicket; +import com.festago.ticketing.domain.TicketSequence; +import com.festago.ticketing.infrastructure.sequence.MemoryTicketSequence; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public class MemoryTicketSequenceRepository implements TicketSequenceRepository { + + private final Map memory = new ConcurrentHashMap<>(); + + @Override + public TicketSequence put(NewTicket ticket) { + MemoryTicketSequence ticketSequence = new MemoryTicketSequence(ticket.getAmount()); + memory.put(ticket.getId(), ticketSequence); + return ticketSequence; + } + + @Override + public Optional findByTicketId(Long ticketId) { + return Optional.ofNullable(memory.get(ticketId)); + } + + @Override + public void delete(NewTicket ticket) { + memory.remove(ticket.getId()); + } +}