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

feat#159/대기열 로직에 티켓 판매 시각 검증 및 티켓팅 진행할 티켓 관리 로직 추가 #163

Merged
merged 12 commits into from
Aug 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
package com.wootecam.festivals.domain.ticket.service;

import com.wootecam.festivals.domain.ticket.repository.TicketInfoRedisRepository;
import com.wootecam.festivals.domain.ticket.repository.TicketStockRedisRepository;
import com.wootecam.festivals.domain.purchase.repository.TicketStockCountRedisRepository;
import com.wootecam.festivals.domain.ticket.dto.TicketResponse;
import com.wootecam.festivals.domain.ticket.repository.CurrentTicketWaitRedisRepository;
import com.wootecam.festivals.domain.ticket.repository.TicketInfoRedisRepository;
import com.wootecam.festivals.domain.ticket.repository.TicketRepository;
import java.time.LocalDateTime;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.support.CronTrigger;
import org.springframework.stereotype.Service;
Expand All @@ -23,7 +23,8 @@ public class TicketScheduleService {
private final TicketRepository ticketRepository;
private final TicketInfoRedisRepository ticketInfoRedisRepository;
private final ThreadPoolTaskScheduler taskScheduler;
private final TicketStockRedisRepository ticketStockRedisRepository;
private final TicketStockCountRedisRepository ticketStockCountRedisRepository;
private final CurrentTicketWaitRedisRepository currentTicketWaitRedisRepository;

/**
* 판매 진행중이거나 앞으로 판매될 티켓의 메타 정보와 재고를 Redis에 저장 - Ticket 의 startSaleTime, endSaleTime, remainStock
Expand All @@ -47,6 +48,8 @@ public void scheduleRedisTicketInfoUpdate() {
else {
updateRedisTicketInfo(ticket);
updateRedisTicketStockCount(ticket);
updateRedisCurrentTicketWait(ticket.id());

log.debug("Redis 티켓 정보 즉시 업데이트 완료 - 티켓 ID: {}, 판매 시작 시각: {}, 판매 종료 시각: {}, 남은 재고: {}", ticket.id(), ticket.startSaleTime(), ticket.endSaleTime(), ticket.remainStock());
}
}
Expand All @@ -62,11 +65,17 @@ private void updateRedisTicketInfo(TicketResponse ticket) {

private void updateRedisTicketStockCount(TicketResponse ticket) {
// Redis 에 남은 티켓 재고 업데이트 (tickets:ticketId:ticketStocks:count)
ticketStockRedisRepository.setTicketStockCount(ticket.id(), ticket.remainStock());
ticketStockCountRedisRepository.setTicketStockCount(ticket.id(), ticket.remainStock());

log.debug("Redis에 저장된 티켓 남은 재고 count 업데이트 - 티켓 ID: {}, 남은 재고: {}", ticket.id(), ticket.remainStock());
}

private void updateRedisCurrentTicketWait(Long ticketId) {
currentTicketWaitRedisRepository.addCurrentTicketWait(ticketId);

log.debug("Redis에 진행할 티켓 ID 추가 - 티켓 ID: {}", ticketId);
}


// 판매 시간 전이라면 판매 10분 전에 스케줄링
private CronTrigger createUpdateRedisTicketCronTrigger(TicketResponse ticket) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,30 +1,34 @@
package com.wootecam.festivals.domain.ticket.service;

import static com.wootecam.festivals.domain.ticket.service.TicketScheduleServiceTestFixture.createMembers;
import static com.wootecam.festivals.domain.ticket.service.TicketScheduleServiceTestFixture.createSaleOngoingTickets;
import static com.wootecam.festivals.domain.ticket.service.TicketScheduleServiceTestFixture.createSaleUpcomingTicketsAfterTenMinutes;
import static com.wootecam.festivals.domain.ticket.service.TicketScheduleServiceTestFixture.createSaleUpcomingTicketsExactlyTenMinutes;
import static com.wootecam.festivals.domain.ticket.service.TicketScheduleServiceTestFixture.createSaleUpcomingTicketsWithinTenMinutes;
import static com.wootecam.festivals.domain.ticket.service.TicketScheduleServiceTestFixture.createUpcomingFestival;
import static org.assertj.core.api.Assertions.assertThat;
import static com.wootecam.festivals.domain.ticket.service.TicketScheduleServiceTestFixture.*;
import static org.assertj.core.api.Assertions.within;

import com.wootecam.festivals.domain.festival.entity.Festival;
import com.wootecam.festivals.domain.festival.repository.FestivalRepository;
import com.wootecam.festivals.domain.member.entity.Member;
import com.wootecam.festivals.domain.member.repository.MemberRepository;
import com.wootecam.festivals.domain.ticket.entity.TicketInfo;
import com.wootecam.festivals.domain.ticket.repository.TicketInfoRedisRepository;
import com.wootecam.festivals.domain.ticket.entity.Ticket;
import com.wootecam.festivals.domain.ticket.entity.TicketInfo;
import com.wootecam.festivals.domain.ticket.entity.TicketStock;
import com.wootecam.festivals.domain.ticket.repository.CurrentTicketWaitRedisRepository;
import com.wootecam.festivals.domain.ticket.repository.TicketInfoRedisRepository;
import com.wootecam.festivals.domain.ticket.repository.TicketRepository;
import com.wootecam.festivals.domain.ticket.repository.TicketStockRedisRepository;
import com.wootecam.festivals.domain.ticket.repository.TicketStockRepository;
import com.wootecam.festivals.utils.SpringBootTestConfig;
import java.time.temporal.ChronoUnit;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;

@SpringBootTest
Expand Down Expand Up @@ -53,7 +57,7 @@ class TicketScheduleServiceTest extends SpringBootTestConfig {
private TicketStockRepository ticketStockRepository;

@Autowired
private TicketStockRedisRepository ticketStockRedisRepository;
private CurrentTicketWaitRedisRepository currentTicketWaitRedisRepository;

private List<Ticket> saleUpcomingTicketsWithinTenMinutes;
private List<Ticket> saleUpcomingTicketsAfterTenMinutes;
Expand Down Expand Up @@ -120,6 +124,11 @@ void itUpdatesAndSchedulesTicketsCorrectly() {
assertThat(ticketInfo).isNull();
});

List<Long> currentTicketWait = currentTicketWaitRedisRepository.getCurrentTicketWait();
saleUpcomingTicketsWithinTenMinutes.forEach(ticket -> {
assertThat(currentTicketWait).contains(ticket.getId());
});

saleOngoingTickets.forEach(ticket -> {
TicketInfo ticketInfo = ticketInfoRedisRepository.getTicketInfo(ticket.getId());
assertThat(ticketInfo).isNotNull();
Expand Down Expand Up @@ -147,6 +156,10 @@ void testUpdateTicketInfoImmediately() {
assertThat(ticketInfo).isNotNull();
assertThat(ticketInfo.startSaleTime()).isCloseTo(ticket.getStartSaleTime(), within(10, ChronoUnit.SECONDS));
assertThat(ticketInfo.endSaleTime()).isCloseTo(ticket.getEndSaleTime(), within(10, ChronoUnit.SECONDS));

List<Long> currentTicketWait = currentTicketWaitRedisRepository.getCurrentTicketWait();
assertThat(currentTicketWait).contains(ticket.getId());

}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,8 @@
import java.time.LocalDateTime;

public record TicketInfo (LocalDateTime startSaleTime, LocalDateTime endSaleTime) {

public boolean isNotOnSale(LocalDateTime now) {
return now.isBefore(startSaleTime) || now.isAfter(endSaleTime);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.wootecam.festivals.domain.ticket.repository;

import java.util.List;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;


/**
* 현재 진행 중인 티켓팅의 티켓 ID를 관리합니다. 자료 구조는 Set을 사용합니다. - key: currentTicketWait
*/
@Repository
public class CurrentTicketWaitRedisRepository extends RedisRepository {

private static final String KEY = "currentTicketWait";

public CurrentTicketWaitRedisRepository(
RedisTemplate<String, String> redisTemplate) {
super(redisTemplate);
}

public void addCurrentTicketWait(Long ticketId) {
redisTemplate.opsForSet().add(KEY, String.valueOf(ticketId));
}

public void removeCurrentTicketWait(Long ticketId) {
redisTemplate.opsForSet().remove(KEY, String.valueOf(ticketId));
}

public List<Long> getCurrentTicketWait() {
return redisTemplate.opsForSet().members(KEY).stream()
.map(Long::parseLong)
.toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public Long decreaseTicketStockCount(Long ticketId) {
/*
lua script 를 이용한 재고 확인 및 재고 있는 경우 차감합니다.
*/
public boolean checkAndDecreaseStock(Long ticketId, Long loginMemberId) {
public boolean checkAndDecreaseStock(Long ticketId) {
String stockKey = createKey(ticketId);

RedisScript<Long> script = RedisScript.of(CHECK_AND_DECREASE_STOCK_SCRIPT, Long.class);
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ export default function (data) {
if (status.paymentStatus === 'SUCCESS') {
break;
}
sleep(3);
sleep(5);
}
}
}
Expand Down
26 changes: 15 additions & 11 deletions backend/k6-monitoring/scripts/wait-event-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import http from 'k6/http';
import {check, group, sleep} from 'k6';
import exec from 'k6/execution';

const MAX_USER = 100;
const BASE_ORIGIN = 'http://localhost:8080';
const MAX_USER = 10000;
const BASE_ORIGIN = 'http://twodari.shop';
const BASE_URL = BASE_ORIGIN + '/api/v1';
const WAIT_ORIGIN = 'http://localhost:8081';
const WAIT_ORIGIN = 'http://twodari.shop';
const WAIT_URL = WAIT_ORIGIN + '/api/v1';

// 테스트 구성 옵션
Expand Down Expand Up @@ -154,8 +154,13 @@ function joinQueue(festivalId, ticketId, sessionCookie) {
}

// 대기열 조회 polling
function getQueuePosition(festivalId, ticketId, sessionCookie, waitOrderBlock) {
const response = http.get(`${WAIT_URL}/festivals/${festivalId}/tickets/${ticketId}/purchase/wait?waitOrderBlock=${waitOrderBlock}`, {
function getQueuePosition(festivalId, ticketId, sessionCookie, waitOrder) {
let url = `${WAIT_URL}/festivals/${festivalId}/tickets/${ticketId}/purchase/wait`;
if (waitOrder !== undefined) {
url += `?waitOrder=${waitOrder}`
}

const response = http.get(url, {
headers: {'Cookie': sessionCookie}
});

Expand Down Expand Up @@ -343,16 +348,15 @@ export default function (data) {
if (festivalDetailResult !== null && ticketListResult !== null) {

// 대기열
const queueInfo = joinQueue(festivalId, ticketId, user.sessionCookie);
sleep(0.5);
let data = getQueuePosition(festivalId, ticketId, user.sessionCookie);
while (true) {
let status = getQueuePosition(festivalId, ticketId, user.sessionCookie, queueInfo.waitOrderBlock);
if (status == null) {
console.log("대기열에서 이탈하였습니다.", user.email)
data = getQueuePosition(festivalId, ticketId, user.sessionCookie, data.absoluteWaitOrder);
// 200 응답이 아닌 경우
if (data == null) {
break;
}

if (status.purchasable === true) {
if (data.purchasable === true) {
break;
}
sleep(3);
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ public enum WaitErrorCode implements ErrorCode, EnumType {
ALREADY_WAITING(HttpStatus.BAD_REQUEST, "WT-0003", "이미 대기 중입니다."),
CANNOT_FOUND_USER(HttpStatus.BAD_REQUEST, "WT-0004", "대기 중인 사용자가 아닙니다."),
NO_STOCK(HttpStatus.BAD_REQUEST, "WT-0005", "재고가 없습니다."),
;
INVALID_TICKET(HttpStatus.BAD_REQUEST, "WT-0006", "유효하지 않은 티켓입니다."),
NOT_ON_SALE(HttpStatus.BAD_REQUEST, "WT-0007", "티켓 판매 시각이 아닙니다.");

private final HttpStatus httpStatus;
private final String code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,19 @@ public PassOrderRedisRepository(RedisTemplate<String, String> redisTemplate) {
super(redisTemplate);
}

public Long get(Long ticketId) {
String passOrderStr = redisTemplate.opsForValue().get(createKey(ticketId));
return passOrderStr == null ? 0 : Long.parseLong(passOrderStr);
}

public void set(Long ticketId, Long passOrder) {
redisTemplate.opsForValue().set(createKey(ticketId), String.valueOf(passOrder));
}

/*
통과 대기열 범위를 증가시킵니다.
- 증가된 값이 현재 대기열 순번보다 작거나 같을 경우 증가된 값을 반환합니다.
- 증가된 값이 현재 대기열 순번보다 클 경우 현재 대기열 순번을 반환합니다.
- 증가된 값이 현재 대기열 순번보다 클 경우 값을 증가시키지 않고 현재 값을 반환합니다.
*/
public Long increase(Long ticketId, Long passOrderChunkSize, Long curWaitOrder) {
String curPassOrderStr = redisTemplate.opsForValue().get(createKey(ticketId));
Expand Down
Loading
Loading