-
Notifications
You must be signed in to change notification settings - Fork 8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[BE] feat: 새로운 티켓팅 로직 추가 (#1007-3) #1010
base: feat/#1007-2
Are you sure you want to change the base?
Changes from 2 commits
c5ec08d
7c82096
40cda4c
0fcce86
0bbe180
6444b88
7b43023
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
package com.festago.ticket.application.command; | ||
|
||
import com.festago.common.exception.ErrorCode; | ||
import com.festago.common.exception.NotFoundException; | ||
import com.festago.ticket.domain.NewTicket; | ||
import com.festago.ticket.repository.NewTicketRepository; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.stereotype.Service; | ||
import org.springframework.transaction.annotation.Transactional; | ||
|
||
@Service | ||
@Transactional | ||
@RequiredArgsConstructor | ||
public class TicketMaxReserveCountChangeService { | ||
|
||
private final NewTicketRepository ticketRepository; | ||
|
||
public void changeMaxReserveAmount(Long ticketId, int maxReserveAmount) { | ||
NewTicket ticket = ticketRepository.findById(ticketId) | ||
.orElseThrow(() -> new NotFoundException(ErrorCode.TICKET_NOT_FOUND)); | ||
ticket.changeMaxReserveAmount(maxReserveAmount); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
package com.festago.ticket.domain.validator.stage; | ||
|
||
import com.festago.common.exception.BadRequestException; | ||
import com.festago.common.exception.ErrorCode; | ||
import com.festago.stage.domain.Stage; | ||
import com.festago.stage.domain.validator.StageDeleteValidator; | ||
import com.festago.ticket.repository.StageTicketRepository; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.stereotype.Component; | ||
|
||
@Component | ||
@RequiredArgsConstructor | ||
public class ExistsTicketStageDeleteValidator implements StageDeleteValidator { | ||
|
||
private final StageTicketRepository stageTicketRepository; | ||
|
||
@Override | ||
public void validate(Stage stage) { | ||
if (stageTicketRepository.existsByStageId(stage.getId())) { | ||
throw new BadRequestException(ErrorCode.STAGE_DELETE_CONSTRAINT_EXISTS_TICKET); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
package com.festago.ticket.domain.validator.stage; | ||
|
||
import com.festago.common.exception.BadRequestException; | ||
import com.festago.common.exception.ErrorCode; | ||
import com.festago.stage.domain.Stage; | ||
import com.festago.stage.domain.validator.StageUpdateValidator; | ||
import com.festago.ticket.repository.StageTicketRepository; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.stereotype.Component; | ||
|
||
@Component | ||
@RequiredArgsConstructor | ||
public class ExistsTicketStageUpdateValidator implements StageUpdateValidator { | ||
|
||
private final StageTicketRepository stageTicketRepository; | ||
|
||
@Override | ||
public void validate(Stage stage) { | ||
if (stageTicketRepository.existsByStageId(stage.getId())) { | ||
throw new BadRequestException(ErrorCode.STAGE_UPDATE_CONSTRAINT_EXISTS_TICKET); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
package com.festago.ticket.repository; | ||
|
||
import com.festago.common.exception.ErrorCode; | ||
import com.festago.common.exception.NotFoundException; | ||
import com.festago.ticket.domain.NewTicket; | ||
import com.festago.ticket.domain.NewTicketType; | ||
import java.util.Optional; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.stereotype.Repository; | ||
|
||
// TODO NewTicket -> Ticket 이름 변경할 것 | ||
@Repository | ||
@RequiredArgsConstructor | ||
public class NewTicketDao { | ||
|
||
private final StageTicketRepository stageTicketRepository; | ||
|
||
public NewTicket findByIdWithTicketTypeAndFetch(Long id, NewTicketType ticketType) { | ||
Optional<? extends NewTicket> ticket = switch (ticketType) { | ||
case STAGE -> stageTicketRepository.findByIdWithFetch(id); | ||
}; | ||
return ticket.orElseThrow(() -> new NotFoundException(ErrorCode.TICKET_NOT_FOUND)); | ||
} | ||
} | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
package com.festago.ticket.repository; | ||
|
||
import com.festago.ticket.domain.NewTicket; | ||
import java.util.Optional; | ||
import org.springframework.data.repository.Repository; | ||
|
||
// TODO NewTicket -> Ticket 이름 변경할 것 | ||
public interface NewTicketRepository extends Repository<NewTicket, Long> { | ||
|
||
NewTicket save(NewTicket ticket); | ||
|
||
Optional<NewTicket> findById(Long id); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
package com.festago.ticketing.application; | ||
|
||
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 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 TicketQuantityEventListener { | ||
|
||
private final TicketQuantityUpdateService ticketQuantityUpdateService; | ||
|
||
@Async | ||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) | ||
public void ticketCreatedEventHandler(TicketCreatedEvent event) { | ||
NewTicket ticket = event.ticket(); | ||
ticketQuantityUpdateService.putOrDeleteTicketQuantity(ticket); | ||
} | ||
|
||
@Async | ||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) | ||
public void ticketDeletedEventHandler(TicketDeletedEvent event) { | ||
NewTicket ticket = event.ticket(); | ||
ticketQuantityUpdateService.putOrDeleteTicketQuantity(ticket); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
package com.festago.ticketing.application.command; | ||
|
||
import com.festago.common.exception.BadRequestException; | ||
import com.festago.common.exception.ErrorCode; | ||
import com.festago.common.exception.NotFoundException; | ||
import com.festago.ticketing.domain.Booker; | ||
import com.festago.ticketing.domain.TicketQuantity; | ||
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 java.util.concurrent.TimeUnit; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.stereotype.Service; | ||
|
||
@Service | ||
@RequiredArgsConstructor | ||
//@Transactional 명시적으로 Transactional 사용하지 않음 | ||
public class QuantityTicketingService { | ||
|
||
private final TicketQuantityRepository ticketQuantityRepository; | ||
private final TicketingCommandService ticketingCommandService; | ||
private final TicketingRateLimiter ticketingRateLimiter; | ||
|
||
public TicketingResult ticketing(TicketingCommand command) { | ||
TicketQuantity ticketQuantity = getTicketQuantity(command.ticketId()); | ||
validateFrequentTicketing(command.booker()); | ||
try { | ||
ticketQuantity.decreaseQuantity(); | ||
return ticketingCommandService.reserveTicket(command); | ||
} catch (Exception e) { | ||
ticketQuantity.increaseQuantity(); | ||
throw e; | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 티켓 재고의 정합을 보장하기 위해 try-catch를 사용하여 예외가 발생하면 다시 재고를 증가시키도록 하였습니다. |
||
} | ||
|
||
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 BadRequestException(ErrorCode.TOO_FREQUENT_REQUESTS); | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 해당 로직이 필요한 이유는 한 명의 사용자가 여러 번 요청을 보내 따닥 문제를 방지하기 위함입니다. |
||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 동시성 문제를 방지하고 높은 처리량을 보장하는 티켓팅 로직의 핵심입니다. 기존 티켓팅 로직은 MySQL의
재고와 시퀀스를 동시에 관리하기 위해 큐를 활용하여 동시 요청을 처리하도록 변경했습니다. 해당 로직은 락을 걸지 않기 때문에 요청이 들어온 만큼 자원을 활용하여 티켓팅 로직을 처리합니다. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
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); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
package com.festago.ticketing.application.command; | ||
|
||
import com.festago.common.exception.BadRequestException; | ||
import com.festago.common.exception.ErrorCode; | ||
import com.festago.ticket.domain.NewTicket; | ||
import com.festago.ticket.domain.NewTicketType; | ||
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; | ||
import com.festago.ticketing.repository.ReserveTicketRepository; | ||
import java.time.Clock; | ||
import java.time.LocalDateTime; | ||
import java.util.List; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.stereotype.Service; | ||
import org.springframework.transaction.annotation.Transactional; | ||
|
||
@Service | ||
@Transactional | ||
@RequiredArgsConstructor | ||
public class TicketingCommandService { | ||
|
||
private final NewTicketDao ticketDao; | ||
private final ReserveTicketRepository reserveTicketRepository; | ||
private final TicketingSequenceGenerator sequenceGenerator; | ||
private final List<TicketingValidator> validators; | ||
private final Clock clock; | ||
|
||
public TicketingResult reserveTicket(TicketingCommand command) { | ||
Long ticketId = command.ticketId(); | ||
NewTicketType ticketType = command.ticketType(); | ||
NewTicket ticket = ticketDao.findByIdWithTicketTypeAndFetch(ticketId, ticketType); | ||
Booker booker = command.booker(); | ||
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)); | ||
return new TicketingResult(reserveTicket.getTicketId()); | ||
} | ||
|
||
private void validate(NewTicket ticket, Booker booker) { | ||
long reserveCount = reserveTicketRepository.countByMemberIdAndTicketId(booker.getMemberId(), ticket.getId()); | ||
if (reserveCount >= ticket.getMaxReserveAmount()) { | ||
throw new BadRequestException(ErrorCode.RESERVE_TICKET_OVER_AMOUNT); | ||
} | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 실제 티켓팅을 담당하는 클래스 입니다. 검증 로직의 경우 티켓 당 허용하는 최대 예매 개수 이상이면 예외를 던지게 하였고, 그 외 다른 도메인의 의존이 필요한 검증은 #1008 PR의 본문에서도 언급했지만, 하나로 합치는 것이 응집도를 높일 수 있겠지만, 따라서 sequence 생성 후에는 절대로 예외가 발생해서는 안 되기에 검증 로직을 별도로 분리했습니다. 만약 sequence 롤백이 가능하도록 해야한다면 레디스 queue를 사용하여 미리 sequence를 만들어 놓고 예외가 발생하면 다시 삽입하는 구조로 가야할 것 같네요. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 그리고 공연 티켓이 아니더라도 다른 유형의 티켓팅을 지원하기 위해 특정 구현체가 아닌 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 추가로 티켓팅 시 퍼포먼스를 더 올리고 싶다면 해당 객체는 읽기에만 사용하고, 티켓팅 시간 이후에는 변경이 막혀있으므로 불변 객체라고 생각해도 무방합니다. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 재고와 시퀀스 둘 다 관리해야하고, 시퀀스를 롤백할 수 없는 문제를 해결하기 위해 기존 CAS 연산으로 수행하던 재고 관리를 큐를 활용하여 재고와 시퀀스 둘 다 관리하도록 변경했습니다. 따라서 실제 티켓팅 로직에서 예외가 발생하더라도 시퀀스가 롤백됩니다! |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
package com.festago.ticketing.domain; | ||
|
||
import com.festago.common.exception.BadRequestException; | ||
|
||
/** | ||
* 티켓의 재고를 관리하는 도메인 <br/> 해당 도메인을 구현하는 구현체는 반드시 원자적인 연산을 사용해야 한다. <br/> | ||
*/ | ||
public interface TicketQuantity { | ||
|
||
/** | ||
* 티켓의 매진 여부를 반환한다. | ||
* | ||
* @return 티켓이 매진이면 true, 매진이 아니면 false | ||
*/ | ||
boolean isSoldOut(); | ||
|
||
/** | ||
* 티켓의 재고를 하나 감소시킨다. <br/> 해당 메서드의 연산은 atomic 해야 한다. <br/> 감소 시킨 뒤 값이 음수인 경우에는 매진이 된 상태에 요청이 들어온 것이므로, 예외를 던져야 한다. | ||
* <br/> | ||
* | ||
* @throws BadRequestException 감소 시킨 뒤 값이 음수이면 | ||
*/ | ||
void decreaseQuantity() throws BadRequestException; | ||
|
||
/** | ||
* 티켓의 재고를 하나 증가시킨다. <br/> 해당 메서드의 연산은 atomic 해야 한다. <br/> | ||
*/ | ||
void increaseQuantity(); | ||
|
||
/** | ||
* 티켓의 남은 재고를 반환한다. <br/> | ||
* | ||
* @return 티켓의 남은 재고 | ||
*/ | ||
int getQuantity(); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
package com.festago.ticketing.domain; | ||
|
||
import java.util.concurrent.TimeUnit; | ||
|
||
public interface TicketingRateLimiter { | ||
|
||
boolean isFrequentTicketing(Booker booker, long timeout, TimeUnit unit); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
package com.festago.ticketing.domain; | ||
|
||
public interface TicketingSequenceGenerator { | ||
|
||
int generate(Long ticketId); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
package com.festago.ticketing.domain.validator; | ||
|
||
import jakarta.annotation.Nullable; | ||
|
||
public interface StageReservedTicketIdResolver { | ||
|
||
/** | ||
* 사용자가 StageTicket에 대해 예매한 티켓의 식별자를 반환합니다. <br/> 예매한 이력이 없으면 null이 반환되고, 예매한 이력이 있으면 사용자가 예매했던 티켓의 식별자를 반환합니다. | ||
* <br/> | ||
* | ||
* @param memberId 티켓을 예매한 사용자의 식별자 | ||
* @param stageId StageTicket의 Stage 식별자 | ||
* @return 예매한 이력이 없으면 null, 예매한 이력이 있으면 티켓 식별자 반환 | ||
*/ | ||
@Nullable | ||
Long resolve(Long memberId, Long stageId); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
package com.festago.ticketing.domain.validator; | ||
|
||
import com.festago.common.exception.BadRequestException; | ||
import com.festago.common.exception.ErrorCode; | ||
import com.festago.ticket.domain.NewTicket; | ||
import com.festago.ticket.domain.StageTicket; | ||
import com.festago.ticketing.domain.Booker; | ||
import java.util.Objects; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.stereotype.Component; | ||
|
||
/** | ||
* 공연의 티켓에 대해 하나의 유형에 대해서만 예매가 가능하도록 검증하는 클래스 <br/> ex) 사용자가 하나의 공연에 대해 학생 전용, 외부인 전용을 모두 예매하는 상황을 방지 <br/> | ||
*/ | ||
@Component | ||
@RequiredArgsConstructor | ||
public class StageSingleTicketTypeTicketingValidator implements TicketingValidator { | ||
|
||
private final StageReservedTicketIdResolver stageReservedTicketIdResolver; | ||
|
||
@Override | ||
public void validate(NewTicket ticket, Booker booker) { | ||
if (!(ticket instanceof StageTicket stageTicket)) { | ||
return; | ||
} | ||
Long memberId = booker.getMemberId(); | ||
Long stageId = stageTicket.getStage().getId(); | ||
Long ticketId = stageReservedTicketIdResolver.resolve(memberId, stageId); | ||
if (ticketId == null || Objects.equals(ticketId, ticket.getId())) { | ||
return; | ||
} | ||
throw new BadRequestException(ErrorCode.ONLY_STAGE_TICKETING_SINGLE_TYPE); | ||
} | ||
} | ||
Comment on lines
+12
to
+34
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 주석에도 설명했지만, 공연에는 두 유형의 티켓이 발급될 수 있습니다.(재학생용, 외부인용) 이때 해당 검증을 하지 않으면 재학생이 두 유형의 티켓을 모두 예매하는 문제가 생길 수 있습니다. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
package com.festago.ticketing.domain.validator; | ||
|
||
import com.festago.ticket.domain.NewTicket; | ||
import com.festago.ticketing.domain.Booker; | ||
|
||
public interface TicketingValidator { | ||
|
||
void validate(NewTicket ticket, Booker booker); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
package com.festago.ticketing.dto; | ||
|
||
public record TicketingResult( | ||
Long reserveTicketId | ||
) { | ||
|
||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NewTicketRepository
를 사용하면 상속 계층의 객체를 알아서 찾아주기 때문에 해당 클래스가 왜 필요하지 싶겠지만, JPA로 상속 관계의 객체를 가져오는 것에는 한계가 존재합니다.첫 번째 문제로는 상속된 모든 객체에 대해 join 쿼리가 발생하기 때문이고, 두 번째 문제는 fetch join을 할 수 없는 문제입니다.
더 정확히는 상속된 객체가
@OneToMany
와 같은 다른 객체와 연관 관계를 맺어 있을 때, 해당 연관 관계를 가진 객체를 fetch join으로 한 번에 가져올 수 없습니다. 😂그렇기 때문에 N+1 문제가 발생하고, 조회가 아닌 티켓팅에서 이러한 문제는 동시 처리 능력을 매우 떨어트리는 원인입니다.
따라서 별도의 객체를 만들어 N+1 문제를 방지하고 다형성을 지원하도록 했습니다.