Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ public enum ErrorCode implements BaseCode {
PACHINKO_NO_MORE_CHANCE(HttpStatus.BAD_REQUEST, "PACHINKO_4002", "이미 세칸을 고르셨습니다."),
PACHINKO_NO_REWARD(HttpStatus.NOT_FOUND, "PACHINKO_4042", "해당 보석 종류에 대한 보상 레코드가 없습니다."),
PACHINKO_NO_PREVIOUS_ROUND(HttpStatus.NOT_FOUND, "PACHINKO_4043", "사용자가 빠칭코 게임을 한 전적이 없어 보상 반환이 불가합니다."),
PACHINKO_ALREADY_SELECT_SQUARE(HttpStatus.OK, "PACHINKO_4003", "해당 칸은 이미 다른 사용자에 의해 선택되었습니다."),
PACHINKO_ALREADY_SELECT_SQUARE(HttpStatus.BAD_REQUEST, "PACHINKO_4003", "해당 칸은 이미 다른 사용자에 의해 선택되었습니다."),
PACHINKO_NO_MORE_JEWEL(HttpStatus.BAD_REQUEST, "PACHINKO_4004", "이미 3칸을 선택하셔서 더 이상 칸을 선택할 수 없습니다."),

// Ranking
RANKING_WEEK_ITEM_LIST_EMPTY(HttpStatus.BAD_REQUEST, "RANKING_4001", "해당 주차에 강화한 상품이 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
Expand All @@ -33,7 +32,6 @@ public class PachinkoWebSocketHandler extends TextWebSocketHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
private final List<WebSocketSession> sessions = new ArrayList<>();
private final ExecutorService virtualThreadExecutor = Executors.newVirtualThreadPerTaskExecutor();
private final Semaphore messageLimiter = new Semaphore(200); // 동시에 200개만 처리

@Override
public void afterConnectionEstablished(WebSocketSession session) {
Expand Down Expand Up @@ -100,10 +98,6 @@ private boolean validateJwt(WebSocketSession session, JsonNode node) throws IOEx
}

private boolean validateUserState(WebSocketSession session, User user, long currentRound) {
if (pachinkoService.noMoreJewel(user)) {
sendMessage(session, "칸을 선택할때 필요한 보석이 부족합니다.");
return false;
}
if (!pachinkoService.canSelectMore(user, currentRound)) {
sendMessage(session, "이미 " + PACHINKO_USER_MAX_SQUARES + "칸을 선택하셔서 더 이상 칸을 선택할 수 없습니다.");
return false;
Expand All @@ -112,15 +106,14 @@ private boolean validateUserState(WebSocketSession session, User user, long curr
}

private void processSquareSelection(WebSocketSession session, User user, long currentRound, int selectedSquare) {
String result = pachinkoService.selectSquare(user, currentRound, selectedSquare);
String result = pachinkoService.selectSquare(user, selectedSquare);
switch (result) {
case "정상적으로 선택 완료되었습니다." -> {
broadcastMessage(user.getNickname() + "가 " + selectedSquare + "을 선택했습니다.");
checkGameStatusAndCloseSessionsIfNeeded();
}
case "다른 사용자가 이전에 선택한 칸입니다." -> sendMessage(session, selectedSquare + "번째 칸은 이미 다른 사용자에 의해 선택되었습니다.");
case "본인이 이전에 선택한 칸입니다." -> sendMessage(session, selectedSquare + "번째 칸은 본인이 이전에 선택한 칸입니다.");
case null, default -> sendMessage(session, "이미 3칸을 선택하셔서 더 이상 칸을 선택할 수 없습니다.");
case "이미 선택된 칸입니다." -> sendMessage(session, selectedSquare + "번째 칸은 이미 다른 사용자에 의해 선택되었습니다.");
case "다른 사용자가 해당 칸을 선택 중입니다." -> sendMessage(session, selectedSquare + "번째 칸은 다른 사용자가 선택중인 칸입니다.");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public interface UserPachinkoRepository extends JpaRepository<UserPachinko, Long

long countByUserAndRound(User user, Long round);

boolean existsByUserAndRoundAndSquare(User user, Long round, Integer square);
boolean existsByRoundAndSquare(Long round, Integer square);

@Query("""
SELECT up FROM UserPachinko up
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,19 @@
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.glassfish.tyrus.client.ClientManager;

public class PachinkoLoadTestWithVerification {

private static final int USERS_PER_SQUARE = 1000;
private static final int TOTAL_SQUARES = 5;
private static final int USERS_PER_SQUARE = 30;
private static final int TOTAL_SQUARES = 36;
private static final String TOKEN_URL = "http://localhost:8080/token/generate";
private static final String WS_URL = "ws://localhost:8080/pachinko";
private static final String VERIFY_URL = "http://localhost:8080/game/pachinko/selected-squares";

private static final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

public static void main(String[] args) throws Exception {
WebSocketContainer container = ClientManager.createClient();
//WebSocketContainer container = ContainerProvider.getWebSocketContainer();
System.out.println("사용 중인 WebSocketContainer 구현체: " + container.getClass().getName());

long start = System.nanoTime();
CountDownLatch latch = new CountDownLatch(USERS_PER_SQUARE * TOTAL_SQUARES);

for (int square = 1; square <= TOTAL_SQUARES; square++) {
Expand All @@ -56,13 +52,17 @@ public static void main(String[] args) throws Exception {
executor.shutdown();
System.out.println("make user finish");

long end = System.nanoTime();

// 결과 검증
Thread.sleep(2000); // 데이터 반영 대기
verifySelectedSquares();

double elapsedMs = (end - start) / 1_000_000.0;
System.out.printf("부하 테스트 완료 – 총 소요 시간: %.2fms (%.2f초)\n", elapsedMs, elapsedMs / 1000.0);
}

private static String getTokenForUser(int userNum) throws Exception {
System.out.println(userNum);
URL url = new URL(TOKEN_URL);
HttpURLConnection con = (HttpURLConnection) url.openConnection();
con.setRequestMethod("POST");
Expand All @@ -87,9 +87,7 @@ private static String getTokenForUser(int userNum) throws Exception {
String response = scanner.hasNext() ? scanner.next() : "";
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(response);
String token = root.get("result").get("accessToken").asText();
System.out.println(token);
return token;
return root.get("result").get("accessToken").asText();

}
} else {
Expand Down Expand Up @@ -127,17 +125,14 @@ private static void verifySelectedSquares() throws Exception {
if (con.getResponseCode() == 200) {
try (Scanner scanner = new Scanner(con.getInputStream()).useDelimiter("\\A")) {
String response = scanner.hasNext() ? scanner.next() : "";
System.out.println("선택된 칸 결과 확인 응답:");
System.out.println(response);

int resultStart = response.indexOf("[");
int resultEnd = response.indexOf("]", resultStart) + 1;
String resultJsonArray = response.substring(resultStart, resultEnd);
String[] squares = resultJsonArray.replaceAll("[\\[\\]\\s]", "").split(",");

System.out.printf("최종 선택된 칸 개수: %d개%n", squares.length);
if (squares.length == TOTAL_SQUARES) {
System.out.printf("테스트 성공: %d개 칸이 정확히 채워졌습니다.", squares.length);
System.out.printf("테스트 성공: %d개 칸이 정확히 채워졌습니다.\n", squares.length);
} else {
System.err.println("테스트 실패: 선택된 칸 수 = " + squares.length);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import LuckyVicky.backend.user.domain.UserJewel;
import LuckyVicky.backend.user.repository.UserJewelRepository;
import LuckyVicky.backend.user.repository.UserRepository;
import LuckyVicky.backend.user.service.UserJewelService;
import jakarta.annotation.PostConstruct;
import jakarta.transaction.Transactional;
import java.security.SecureRandom;
Expand All @@ -31,13 +32,11 @@
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;

@Slf4j
Expand All @@ -61,10 +60,12 @@ public class PachinkoService {
private final UserJewelRepository userJewelRepository;
private final UserRepository userRepository;
private final DisplayBoardService displayBoardService;
private final UserJewelService userJewelService;
private final FcmService fcmService;

@Getter
private final Set<Integer> selectedSquares = ConcurrentHashMap.newKeySet();
private final Set<Integer> selectedSquares = ConcurrentHashMap.newKeySet(); // 캐시에 미리 선택되지 않았다는 데이터 넣어두자
private final ConcurrentHashMap<Integer, ReentrantLock> squareLocks = new ConcurrentHashMap<>();

public Set<Integer> viewSelectedSquares() { // 읽기 전용 뷰 반환
return Collections.unmodifiableSet(selectedSquares);
Expand Down Expand Up @@ -99,71 +100,54 @@ public void startNewRound() {
assignRewardsToSquares(currentRound);
}

@Transactional
public boolean noMoreJewel(User user) {
UserJewel userJewel = userJewelRepository.findByUserAndJewelType(user, PACHINKO_NEED_JEWEL_TYPE)
.orElseThrow(() -> new GeneralException(ErrorCode.USER_JEWEL_NOT_FOUND));

return userJewel.getCount() < PACHINKO_NEED_JEWEL_COUNT;
}

@Transactional
public boolean canSelectMore(User user, Long round) {
return userPachinkoRepository.countByUserAndRound(user, round) < PACHINKO_USER_MAX_SQUARES;
}

@Transactional
@Retryable(
value = DataIntegrityViolationException.class,
maxAttempts = 2,
backoff = @Backoff(delay = 100, multiplier = 2)
)
public String selectSquare(User user, long currentRound, int squareNumber) {
public String selectSquare(User user, int squareNumber) {
// 칸 번호 유효성 검증
validateSquareNumber(squareNumber);

// 이미 선택된 칸인지 확인
// 캐시 검증
if (selectedSquares.contains(squareNumber)) {
log.info("{} 이미 {}가 선택되었습니다.", selectedSquares, squareNumber);

boolean userAlreadySelected = userPachinkoRepository.existsByUserAndRoundAndSquare(user, currentRound,
squareNumber);
return "이미 선택된 칸입니다.";
}

if (userAlreadySelected) {
log.info("본인이 이전에 선택한 칸입니다.");
return "본인이 이전에 선택한 칸입니다.";
} else {
log.info("다른 사용자가 이전에 선택한 칸입니다.");
return "다른 사용자가 이전에 선택한 칸입니다.";
}
// lock 획득 시도
ReentrantLock lock = squareLocks.computeIfAbsent(squareNumber, key -> new ReentrantLock());
if (!lock.tryLock()) {
return "다른 사용자가 해당 칸을 선택 중입니다.";
}
try {
// DB, 캐시 갱신 이전 재확인
if (selectedSquares.contains(squareNumber)) {
return "이미 선택된 칸입니다.";
}

// 사용자 Pachinko 상태 저장
userPachinkoRepository.save(PachinkoConverter.saveUserPachinko(user, currentRound, squareNumber));
log.info("user pachinko에 선택한 칸인 {}을 저장했습니다.", squareNumber);
// 보석 개수 확인 후 차감
userJewelService.deductUserJewel(user);
log.info("빠칭코 칸 선택을 위해 B급 보석 하나를 지불하여 DB에서 보석을 차감했습니다.");

// 선택한 칸을 set에 추가
addSelectedSquare(squareNumber);
log.info("선택한 칸을 set에 삽입했습니다. 변경된 set: {}", selectedSquares);
// 캐시 갱신
addSelectedSquare(squareNumber);
log.info("선택한 칸을 set에 삽입했습니다. 변경된 set: {}", selectedSquares);

// 보석 차감
deductUserJewel(user);
log.info("빠칭코 칸 선택을 위해 B급 보석 하나를 지불하여 DB에서 보석을 차감했습니다.");
// DB 갱신
userPachinkoRepository.save(PachinkoConverter.saveUserPachinko(user, currentRound, squareNumber));
log.info("user pachinko에 선택한 칸인 {}을 저장했습니다.", squareNumber);

return "정상적으로 선택 완료되었습니다.";
return "정상적으로 선택 완료되었습니다.";
} finally {
lock.unlock();
}
}

public void addSelectedSquare(int square) {
selectedSquares.add(square);
}

private void deductUserJewel(User user) {
UserJewel userJewel = userJewelRepository.findByUserAndJewelType(user, PACHINKO_NEED_JEWEL_TYPE)
.orElseThrow(() -> new GeneralException(ErrorCode.USER_JEWEL_NOT_FOUND));
userJewel.decreaseCount(PACHINKO_NEED_JEWEL_COUNT);
userJewelRepository.save(userJewel);
}

private void validateSquareNumber(int squareNumber) {
if (squareNumber < PACHINKO_MIN_SQUARE_NUMBER || squareNumber > PACHINKO_TOTAL_SQUARE_COUNT) {
throw new GeneralException(ErrorCode.PACHINKO_OUT_OF_BOUND);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
import LuckyVicky.backend.enhance.domain.JewelType;
import LuckyVicky.backend.user.domain.User;
import LuckyVicky.backend.user.domain.UserJewel;
import jakarta.persistence.LockModeType;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.stereotype.Repository;

@Repository
public interface UserJewelRepository extends JpaRepository<UserJewel, Long> {
UserJewel findFirstByUserAndJewelType(User user, JewelType jewelType);

@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<UserJewel> findByUserAndJewelType(User user, JewelType jewelType);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package LuckyVicky.backend.user.service;

import LuckyVicky.backend.enhance.domain.JewelType;
import LuckyVicky.backend.global.api_payload.ErrorCode;
import LuckyVicky.backend.global.exception.GeneralException;
import LuckyVicky.backend.user.domain.User;
import LuckyVicky.backend.user.domain.UserJewel;
import LuckyVicky.backend.user.repository.UserJewelRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
@Slf4j
public class UserJewelService {

public final UserJewelRepository userJewelRepository;
private static final JewelType PACHINKO_NEED_JEWEL_TYPE = JewelType.B;
private static final int PACHINKO_NEED_JEWEL_COUNT = 1;

@Transactional
public void deductUserJewel(User user) {
// DB에서 락 걸고 사용자 보석 정보 조회
UserJewel userJewel = userJewelRepository.findByUserAndJewelType(user, PACHINKO_NEED_JEWEL_TYPE)
.orElseThrow(() -> new GeneralException(ErrorCode.USER_JEWEL_NOT_FOUND));
// 선택을 위한 코색 개수가 있는지
if (userJewel.getCount() < PACHINKO_NEED_JEWEL_COUNT) {
throw new GeneralException(ErrorCode.PACHINKO_NO_MORE_JEWEL);
}
// 보석 수량 차감
userJewel.decreaseCount(PACHINKO_NEED_JEWEL_COUNT);
userJewelRepository.save(userJewel);
}
}