diff --git a/server/src/intTest/java/com/ticketing/server/movie/service/TicketServiceImplTest.java b/server/src/intTest/java/com/ticketing/server/movie/service/TicketServiceImplTest.java new file mode 100644 index 0000000..a9b3788 --- /dev/null +++ b/server/src/intTest/java/com/ticketing/server/movie/service/TicketServiceImplTest.java @@ -0,0 +1,56 @@ +package com.ticketing.server.movie.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class TicketServiceImplTest { + + @Autowired + private TicketServiceImpl ticketService; + + @Test + @DisplayName("티켓 lock 동시성 체크") + @SuppressWarnings({"java:S5960"}) + void ticketMultiThread() throws InterruptedException { + // given + ExecutorService executorService = Executors.newFixedThreadPool(2); + CountDownLatch latch = new CountDownLatch(2); + + List lockIds = List.of("TicketLock:1", "TicketLock:2", "TicketLock:3"); + AtomicBoolean result1 = new AtomicBoolean(Boolean.TRUE); + AtomicBoolean result2 = new AtomicBoolean(Boolean.TRUE); + + // when + executorService.execute(() -> { + result1.set(ticketService.isEveryTicketIdLock(lockIds)); + latch.countDown(); + }); + + executorService.execute(() -> { + result2.set(ticketService.isEveryTicketIdLock(List.of("TicketLock:1"))); + latch.countDown(); + }); + + latch.await(); + + // then + Long unlockCount = ticketService.ticketIdsUnlock(lockIds); + + assertAll( + () -> assertThat(result1).isNotEqualTo(result2), + () -> assertThat(unlockCount > 1).isTrue() + ); + } + +} diff --git a/server/src/main/java/com/ticketing/server/global/exception/ErrorCode.java b/server/src/main/java/com/ticketing/server/global/exception/ErrorCode.java index cff84fb..113b1e0 100644 --- a/server/src/main/java/com/ticketing/server/global/exception/ErrorCode.java +++ b/server/src/main/java/com/ticketing/server/global/exception/ErrorCode.java @@ -5,7 +5,6 @@ import static org.springframework.http.HttpStatus.CONFLICT; import static org.springframework.http.HttpStatus.FORBIDDEN; import static org.springframework.http.HttpStatus.NOT_FOUND; -import com.ticketing.server.global.redis.PaymentCache; import lombok.AllArgsConstructor; import lombok.Getter; import org.springframework.http.HttpStatus; @@ -24,6 +23,8 @@ public enum ErrorCode { BAD_REQUEST_PAYMENT_COMPLETE(BAD_REQUEST, "처리할 결제 정보가 존재하지 않습니다."), BAD_REQUEST_PAYMENT_READY(BAD_REQUEST, "이미 진행 중인 결제가 존재합니다."), BAD_REQUEST_PAYMENT_CANCEL(BAD_REQUEST, "취소할 티켓이 존재하지 않습니다."), + BAD_REQUEST_TICKET_RESERVATION(BAD_REQUEST, "이미 다른 고객이 예약 진행 중인 좌석이 존재합니다."), + BAD_REQUEST_TICKET_SOLD(BAD_REQUEST, "이미 환불 진행 중 입니다."), NOT_REFUNDABLE_TIME(BAD_REQUEST, "환불이 가능한 시간이 지났습니다."), NOT_REFUNDABLE_SEAT(BAD_REQUEST, "환불할 수 있는 좌석이 아닙니다."), @@ -48,7 +49,7 @@ public enum ErrorCode { DELETED_MOVIE(CONFLICT, "이미 삭제된 영화 입니다."); private final HttpStatus httpStatus; - private String detail; + private final String detail; /* 400 BAD_REQUEST : 잘못된 요청 */ public static TicketingException throwMismatchPassword() { @@ -67,14 +68,6 @@ public enum ErrorCode { throw new TicketingException(UNABLE_CHANGE_GRADE); } - public static TicketingException throwInvalidTicketId() { - throw new TicketingException(INVALID_TICKET_ID); - } - - public static TicketingException throwBadRequestMovieTime() { - throw new TicketingException(BAD_REQUEST_MOVIE_TIME); - } - public static TicketingException throwBadRequestPaymentComplete() { throw new TicketingException(BAD_REQUEST_PAYMENT_COMPLETE); } @@ -83,27 +76,12 @@ public enum ErrorCode { throw new TicketingException(BAD_REQUEST_PAYMENT_READY); } - public static TicketingException throwBadRequestPaymentCancel() { - throw new TicketingException(BAD_REQUEST_PAYMENT_CANCEL); - } - - public static TicketingException throwNotRefundableTime() { - throw new TicketingException(NOT_REFUNDABLE_TIME); - } - - public static TicketingException throwNotRefundableSeat() { - throw new TicketingException(NOT_REFUNDABLE_SEAT); - } - /* 403 FORBIDDEN : 접근 권한 제한 */ public static TicketingException throwValidUserId() { throw new TicketingException(VALID_USER_ID); } /* 404 NOT_FOUND : Resource 를 찾을 수 없음 */ - public static TicketingException throwUserNotFound() { - throw new TicketingException(USER_NOT_FOUND); - } public static TicketingException throwEmailNotFound() { throw new TicketingException(EMAIL_NOT_FOUND); @@ -113,10 +91,6 @@ public enum ErrorCode { throw new TicketingException(MOVIE_NOT_FOUND); } - public static TicketingException throwMovieTimeNotFound() { - throw new TicketingException(MOVIE_TIME_NOT_FOUND); - } - public static TicketingException throwRefreshTokenNotFound() { throw new TicketingException(REFRESH_TOKEN_NOT_FOUND); } diff --git a/server/src/main/java/com/ticketing/server/movie/domain/Ticket.java b/server/src/main/java/com/ticketing/server/movie/domain/Ticket.java index 8f12867..16ed074 100644 --- a/server/src/main/java/com/ticketing/server/movie/domain/Ticket.java +++ b/server/src/main/java/com/ticketing/server/movie/domain/Ticket.java @@ -1,7 +1,13 @@ package com.ticketing.server.movie.domain; +import static com.ticketing.server.global.exception.ErrorCode.BAD_REQUEST_PAYMENT_CANCEL; +import static com.ticketing.server.global.exception.ErrorCode.DUPLICATE_PAYMENT; +import static com.ticketing.server.global.exception.ErrorCode.NOT_REFUNDABLE_SEAT; +import static com.ticketing.server.global.exception.ErrorCode.NOT_REFUNDABLE_TIME; + import com.ticketing.server.global.dto.repository.AbstractEntity; import com.ticketing.server.global.exception.ErrorCode; +import com.ticketing.server.global.exception.TicketingException; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import javax.persistence.Entity; @@ -56,7 +62,7 @@ public class Ticket extends AbstractEntity { public Ticket makeReservation() { if (!TicketStatus.SALE.equals(status)) { - throw ErrorCode.throwDuplicatePayment(); + throw new TicketingException(DUPLICATE_PAYMENT); } status = TicketStatus.RESERVATION; @@ -65,7 +71,7 @@ public class Ticket extends AbstractEntity { public Ticket makeSold(Long paymentId) { if (TicketStatus.SOLD.equals(status)) { - throw ErrorCode.throwDuplicatePayment(); + throw new TicketingException(DUPLICATE_PAYMENT); } status = TicketStatus.SOLD; @@ -75,7 +81,7 @@ public class Ticket extends AbstractEntity { public Ticket cancel() { if (!TicketStatus.RESERVATION.equals(status)) { - throw ErrorCode.throwBadRequestPaymentCancel(); + throw new TicketingException(BAD_REQUEST_PAYMENT_CANCEL); } status = TicketStatus.SALE; @@ -86,7 +92,7 @@ public class Ticket extends AbstractEntity { public Ticket refund(LocalDateTime dateTime) { long seconds = ChronoUnit.SECONDS.between(dateTime, getStartAt()); if (600L > seconds) { - throw ErrorCode.throwNotRefundableTime(); + throw new TicketingException(NOT_REFUNDABLE_TIME); } return refund(); @@ -94,7 +100,7 @@ public class Ticket extends AbstractEntity { public Ticket refund() { if (!TicketStatus.SOLD.equals(status)) { - throw ErrorCode.throwNotRefundableSeat(); + throw new TicketingException(NOT_REFUNDABLE_SEAT); } status = TicketStatus.SALE; diff --git a/server/src/main/java/com/ticketing/server/movie/domain/TicketLock.java b/server/src/main/java/com/ticketing/server/movie/domain/TicketLock.java new file mode 100644 index 0000000..4bd3eaf --- /dev/null +++ b/server/src/main/java/com/ticketing/server/movie/domain/TicketLock.java @@ -0,0 +1,15 @@ +package com.ticketing.server.movie.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TicketLock { + + LOCK_KEY("TicketLock"), + LOCK_VALUE("lock"); + + private final String value; + +} diff --git a/server/src/main/java/com/ticketing/server/movie/service/TicketServiceImpl.java b/server/src/main/java/com/ticketing/server/movie/service/TicketServiceImpl.java index 15a5ef7..79c9387 100644 --- a/server/src/main/java/com/ticketing/server/movie/service/TicketServiceImpl.java +++ b/server/src/main/java/com/ticketing/server/movie/service/TicketServiceImpl.java @@ -1,6 +1,14 @@ package com.ticketing.server.movie.service; +import static com.ticketing.server.global.exception.ErrorCode.BAD_REQUEST_MOVIE_TIME; +import static com.ticketing.server.global.exception.ErrorCode.INVALID_TICKET_ID; +import static com.ticketing.server.global.exception.ErrorCode.MOVIE_TIME_NOT_FOUND; +import static com.ticketing.server.global.exception.ErrorCode.PAYMENT_ID_NOT_FOUND; +import static com.ticketing.server.movie.domain.TicketLock.LOCK_KEY; +import static com.ticketing.server.movie.domain.TicketLock.LOCK_VALUE; + import com.ticketing.server.global.exception.ErrorCode; +import com.ticketing.server.global.exception.TicketingException; import com.ticketing.server.global.validator.constraints.NotEmptyCollection; import com.ticketing.server.movie.domain.MovieTime; import com.ticketing.server.movie.domain.Ticket; @@ -16,11 +24,13 @@ import com.ticketing.server.movie.service.dto.TicketsSoldDTO; import com.ticketing.server.movie.service.interfaces.TicketService; import com.ticketing.server.payment.service.dto.TicketDetailDTO; import java.util.List; +import java.util.concurrent.TimeUnit; import java.util.function.UnaryOperator; import java.util.stream.Collectors; import javax.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; @@ -33,13 +43,14 @@ import org.springframework.validation.annotation.Validated; public class TicketServiceImpl implements TicketService { private final TicketRepository ticketRepository; - private final MovieTimeRepository movieTimeRepository; + private final RedisTemplate redisTemplate; + @Override public List getTickets(@NotNull Long movieTimeId) { MovieTime movieTime = movieTimeRepository.findById(movieTimeId) - .orElseThrow(ErrorCode::throwMovieTimeNotFound); + .orElseThrow(() -> new TicketingException(MOVIE_TIME_NOT_FOUND)); return ticketRepository.findValidTickets(movieTime) .stream() @@ -55,7 +66,7 @@ public class TicketServiceImpl implements TicketService { .collect(Collectors.toList()); if (ticketDetails.isEmpty()) { - throw ErrorCode.throwPaymentIdNotFound(); + throw new TicketingException(PAYMENT_ID_NOT_FOUND); } return ticketDetails; @@ -65,32 +76,50 @@ public class TicketServiceImpl implements TicketService { @Transactional public TicketsReservationDTO ticketReservation(@NotEmptyCollection List ticketIds) { List tickets = getTicketsByInTicketIds(ticketIds); + List ticketLockIds = makeTicketLockIds(ticketIds); - Long firstMovieTimeId = firstMovieTimeId(tickets); - List reservationDtoList = tickets.stream() - .map(Ticket::makeReservation) - .filter(ticket -> firstMovieTimeId.equals(ticket.getMovieTimeId())) - .map(TicketReservationDTO::new) - .collect(Collectors.toList()); + try { + if (!isEveryTicketIdLock(ticketLockIds)) { + throw new TicketingException(ErrorCode.BAD_REQUEST_TICKET_RESERVATION); + } - if (ticketIds.size() != reservationDtoList.size()) { - throw ErrorCode.throwBadRequestMovieTime(); + Long firstMovieTimeId = firstMovieTimeId(tickets); + List reservationDtoList = tickets.stream() + .map(Ticket::makeReservation) + .filter(ticket -> firstMovieTimeId.equals(ticket.getMovieTimeId())) + .map(TicketReservationDTO::new) + .collect(Collectors.toList()); + + if (ticketIds.size() != reservationDtoList.size()) { + throw new TicketingException(BAD_REQUEST_MOVIE_TIME); + } + + return new TicketsReservationDTO(firstMovieTitle(tickets), reservationDtoList); + } finally { + ticketIdsUnlock(ticketLockIds); } - - return new TicketsReservationDTO(firstMovieTitle(tickets), reservationDtoList); } @Override @Transactional public TicketsSoldDTO ticketSold(@NotNull Long paymentId, @NotEmptyCollection List ticketIds) { List tickets = getTicketsByInTicketIds(ticketIds); + List ticketLockIds = makeTicketLockIds(ticketIds); - List soldDtoList = tickets.stream() - .map(ticket -> ticket.makeSold(paymentId)) - .map(TicketSoldDTO::new) - .collect(Collectors.toList()); + try { + if (!isEveryTicketIdLock(ticketLockIds)) { + throw new TicketingException(ErrorCode.BAD_REQUEST_TICKET_SOLD); + } - return new TicketsSoldDTO(paymentId, soldDtoList); + List soldDtoList = tickets.stream() + .map(ticket -> ticket.makeSold(paymentId)) + .map(TicketSoldDTO::new) + .collect(Collectors.toList()); + + return new TicketsSoldDTO(paymentId, soldDtoList); + } finally { + ticketIdsUnlock(ticketLockIds); + } } @Override @@ -113,11 +142,30 @@ public class TicketServiceImpl implements TicketService { .collect(Collectors.toList()); } + protected boolean isEveryTicketIdLock(List ids) { + for (String id : ids) { + if (Boolean.FALSE.equals(redisTemplate.opsForValue().setIfAbsent(id, LOCK_VALUE.getValue(), 5, TimeUnit.MINUTES))) { + return Boolean.FALSE; + } + } + return Boolean.TRUE; + } + + protected Long ticketIdsUnlock(List ids) { + return redisTemplate.delete(ids); + } + + private List makeTicketLockIds(List ticketIds) { + return ticketIds.stream() + .map(id -> LOCK_KEY.getValue() + ":" + id) + .collect(Collectors.toList()); + } + private List getTicketsByInTicketIds(List ticketIds) { List tickets = ticketRepository.findTicketFetchJoinByTicketIds(ticketIds); if (tickets.size() != ticketIds.size()) { - throw ErrorCode.throwInvalidTicketId(); + throw new TicketingException(INVALID_TICKET_ID); } return tickets;