feat: 티켓 구매, 환불 메서드 분산 락 적용

This commit is contained in:
dongHyo
2022-07-22 14:52:44 +09:00
parent 42a00d20d0
commit 3e65a3a2e7
5 changed files with 152 additions and 53 deletions

View File

@@ -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<String> 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()
);
}
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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<String, Object> redisTemplate;
@Override
public List<TicketDTO> 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<Long> ticketIds) {
List<Ticket> tickets = getTicketsByInTicketIds(ticketIds);
List<String> ticketLockIds = makeTicketLockIds(ticketIds);
Long firstMovieTimeId = firstMovieTimeId(tickets);
List<TicketReservationDTO> 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<TicketReservationDTO> 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<Long> ticketIds) {
List<Ticket> tickets = getTicketsByInTicketIds(ticketIds);
List<String> ticketLockIds = makeTicketLockIds(ticketIds);
List<TicketSoldDTO> 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<TicketSoldDTO> 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<String> 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<String> ids) {
return redisTemplate.delete(ids);
}
private List<String> makeTicketLockIds(List<Long> ticketIds) {
return ticketIds.stream()
.map(id -> LOCK_KEY.getValue() + ":" + id)
.collect(Collectors.toList());
}
private List<Ticket> getTicketsByInTicketIds(List<Long> ticketIds) {
List<Ticket> tickets = ticketRepository.findTicketFetchJoinByTicketIds(ticketIds);
if (tickets.size() != ticketIds.size()) {
throw ErrorCode.throwInvalidTicketId();
throw new TicketingException(INVALID_TICKET_ID);
}
return tickets;