Skip to content
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

Open
wants to merge 7 commits into
base: feat/#1007-2
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

public class TooManyRequestException extends FestaGoException {

public TooManyRequestException() {
this(ErrorCode.TOO_FREQUENT_REQUESTS);
}

public TooManyRequestException(ErrorCode errorCode) {
super(errorCode);
}
Expand Down
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
Expand Up @@ -5,6 +5,7 @@
import com.festago.ticketing.domain.Booker;
import com.festago.ticketing.domain.ReserveTicket;
import jakarta.persistence.Column;
import jakarta.persistence.DiscriminatorColumn;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
Expand All @@ -21,6 +22,7 @@
// TODO NewTicket -> Ticket 이름 변경할 것
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public abstract class NewTicket extends BaseTimeEntity {

Expand Down
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));
}
}
Comment on lines +12 to +24
Copy link
Collaborator Author

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 문제를 방지하고 다형성을 지원하도록 했습니다.

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.TicketSequenceUpdateService;
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 TicketSequenceEventListener {

private final TicketSequenceUpdateService ticketSequenceUpdateService;

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void ticketCreatedEventHandler(TicketCreatedEvent event) {
NewTicket ticket = event.ticket();
ticketSequenceUpdateService.putOrDeleteTicketSequence(ticket);
}

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void ticketDeletedEventHandler(TicketDeletedEvent event) {
NewTicket ticket = event.ticket();
ticketSequenceUpdateService.putOrDeleteTicketSequence(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.common.exception.NotFoundException;
import com.festago.common.exception.TooManyRequestException;
import com.festago.ticketing.domain.Booker;
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.TicketSequenceRepository;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
//@Transactional 명시적으로 Transactional 사용하지 않음
public class SequenceTicketingService {

private final TicketingCommandService ticketingCommandService;
private final TicketSequenceRepository ticketSequenceRepository;
private final TicketingRateLimiter ticketingRateLimiter;

public TicketingResult ticketing(TicketingCommand command) {
Long ticketId = command.ticketId();
TicketSequence ticketSequence = getTicketSequence(ticketId);
validateFrequentTicketing(command.booker());
int sequence = ticketSequence.reserve();
try {
return ticketingCommandService.ticketing(command, sequence);
} catch (Exception e) {
ticketSequence.cancel(sequence);
throw e;
}
}

private void validateFrequentTicketing(Booker booker) {
if (ticketingRateLimiter.isFrequentTicketing(booker, 5, TimeUnit.SECONDS)) {
throw new TooManyRequestException();
}
}
Comment on lines +39 to +43
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RateLimit 시간을 어떻게 조절해야 할 지 모르겠네요.
5초면 조금 긴 것 같기도 하고...
1초 정도만 하더라도 동시성 문제를 막기 충분한 것 같기도 하고.. 아니면 스핀락 방식으로 대기를 하도록 해야할지 모르겠네요. 😂


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;
}
}
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.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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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.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 List<TicketingValidator> validators;
private final Clock clock;

public TicketingResult ticketing(TicketingCommand command, int sequence) {
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);
ReserveTicket reserveTicket = ticket.reserve(booker, sequence);
reserveTicketRepository.save(reserveTicket);
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);
}
}
}
Comment on lines +21 to +50
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

서비스 레이어는 비즈니스 로직을 갖지 않고, 도메인 레이어가 모든 비즈니스 로직을 갖게 했습니다.

다만 검증부가 조금 거슬리는데, 해당 검증을 Validator로 이동시켜야 할지 모르겠네요.

정답이 없는 문제이긴 한데, Validator로 옮긴다면 서비스 레이어 코드에는 단순히 트랜잭션을 적용하고 흐름을 제어하는 로직만 남게되니 깔끔해지긴 하는데, 검증부가 흩어져있어 가독성이 조금 낮습니다.

최대 예매 수량의 경우는 기본적인 검증에 해당하는 로직이라 여기 남겨두긴 했는데.. 이걸 옮겨야 할 지는 모르겠습니다. 😂

Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.festago.ticketing.domain;

import com.festago.common.exception.BadRequestException;

/**
* 티켓의 재고와 순서를 관리하는 도메인 <br/> 해당 도메인을 구현하는 구현체는 반드시 원자적인 연산을 사용해야 한다. <br/>
*/
public interface TicketSequence {

/**
* 티켓의 매진 여부를 반환한다.
*
* @return 티켓이 매진이면 true, 매진이 아니면 false
*/
boolean isSoldOut();

/**
* 티켓의 재고를 하나 감소시키고 순서를 반환한다. <br/> 해당 메서드의 연산은 atomic 해야 한다. <br/> 티켓의 재고가 비어있을 때 해당 메서드를 호출하면 BadRequestException을
* 던져야 한다. <br/>
*
* @throws BadRequestException 티켓의 재고가 비어있으면
*/
int reserve() throws BadRequestException;

/**
* 티켓의 재고를 하나 증가시키고 인자로 들어온 순서를 다시 보관한다. <br/> 해당 메서드의 연산은 atomic 해야 한다. <br/>
*
* @param sequence 티켓의 순서
*/
void cancel(int sequence);

/**
* 티켓의 남은 재고를 반환한다. <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,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
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

주석에도 설명했지만, 공연에는 두 유형의 티켓이 발급될 수 있습니다.(재학생용, 외부인용)

이때 해당 검증을 하지 않으면 재학생이 두 유형의 티켓을 모두 예매하는 문제가 생길 수 있습니다.
(정책 상 달라질 수 있겠지만, 아마 거의 이런 경우를 원하지는 않을 것 같네요)

Loading
Loading