diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index 92ac213..6943b01 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -1,69 +1,69 @@ -name: LuckyVicky CI/CD - -on: - pull_request: - types: - - closed - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'develop' - - steps: - # 1. GitHub 저장소에서 코드 체크아웃 - - name: Checkout - uses: actions/checkout@v4 - - # 2. JDK 17 설정 - - name: Set up JDK 17 - uses: actions/setup-java@v4.0.0 - with: - java-version: '17' - distribution: 'adopt' - - # 3. gradlew 파일 실행 권한 추가 - - name: Grant execute permission for gradlew - run: chmod +x gradlew - shell: bash - - # 4. Gradle 빌드 (테스트는 제외) - - name: Build with Gradle - run: ./gradlew clean build -x test - shell: bash - - # 5. 현재 시간 가져오기 (배포 버전 레이블에 사용) - - name: Get current time - uses: josStorer/get-current-time@v2 - id: current-time - with: - format: 'YYYY-MM-DDTHH:mm:ss' - utcOffset: '+09:00' - - - name: Show current time - run: echo "${{ steps.current-time.outputs.formattedTime }}" - shell: bash - - # 6. 배포 패키지 생성 - - name: Generate deployment package - run: | - mkdir -p deploy - cp build/libs/*.jar deploy/application.jar - cp Procfile deploy/Procfile - cp -r .ebextensions_dev deploy/.ebextensions - cp -r .platform deploy/.platform - cd deploy && zip -r deploy.zip . - - # 7. Elastic Beanstalk으로 배포 - - name: Deploy to Elastic Beanstalk - uses: einaregilsson/beanstalk-deploy@v20 - with: - aws_access_key: ${{ secrets.AWS_ACTION_ACCESS_KEY_ID }} - aws_secret_key: ${{ secrets.AWS_ACTION_SECRET_ACCESS_KEY }} - application_name: 'persi-dev' - environment_name: 'Persi-dev-env' - version_label: github-action-${{ steps.current-time.outputs.formattedTime }} - region: 'ap-northeast-2' - deployment_package: 'deploy/deploy.zip' - wait_for_deployment: false \ No newline at end of file +#name: LuckyVicky CI/CD +# +#on: +# pull_request: +# types: +# - closed +# workflow_dispatch: +# +#jobs: +# build: +# runs-on: ubuntu-latest +# if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'develop' +# +# steps: +# # 1. GitHub 저장소에서 코드 체크아웃 +# - name: Checkout +# uses: actions/checkout@v4 +# +# # 2. JDK 17 설정 +# - name: Set up JDK 17 +# uses: actions/setup-java@v4.0.0 +# with: +# java-version: '17' +# distribution: 'adopt' +# +# # 3. gradlew 파일 실행 권한 추가 +# - name: Grant execute permission for gradlew +# run: chmod +x gradlew +# shell: bash +# +# # 4. Gradle 빌드 (테스트는 제외) +# - name: Build with Gradle +# run: ./gradlew clean build -x test +# shell: bash +# +# # 5. 현재 시간 가져오기 (배포 버전 레이블에 사용) +# - name: Get current time +# uses: josStorer/get-current-time@v2 +# id: current-time +# with: +# format: 'YYYY-MM-DDTHH:mm:ss' +# utcOffset: '+09:00' +# +# - name: Show current time +# run: echo "${{ steps.current-time.outputs.formattedTime }}" +# shell: bash +# +# # 6. 배포 패키지 생성 +# - name: Generate deployment package +# run: | +# mkdir -p deploy +# cp build/libs/*.jar deploy/application.jar +# cp Procfile deploy/Procfile +# cp -r .ebextensions_dev deploy/.ebextensions +# cp -r .platform deploy/.platform +# cd deploy && zip -r deploy.zip . +# +# # 7. Elastic Beanstalk으로 배포 +# - name: Deploy to Elastic Beanstalk +# uses: einaregilsson/beanstalk-deploy@v20 +# with: +# aws_access_key: ${{ secrets.AWS_ACTION_ACCESS_KEY_ID }} +# aws_secret_key: ${{ secrets.AWS_ACTION_SECRET_ACCESS_KEY }} +# application_name: 'persi-dev' +# environment_name: 'Persi-dev-env' +# version_label: github-action-${{ steps.current-time.outputs.formattedTime }} +# region: 'ap-northeast-2' +# deployment_package: 'deploy/deploy.zip' +# wait_for_deployment: false \ No newline at end of file diff --git a/build.gradle b/build.gradle index 7fb6464..7718342 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ version = '0.0.1-SNAPSHOT' java { toolchain { - languageVersion = JavaLanguageVersion.of(17) + languageVersion = JavaLanguageVersion.of(21) } } @@ -64,6 +64,11 @@ dependencies { testImplementation 'org.springframework.security:spring-security-test' testImplementation 'org.springframework.boot:spring-boot-starter-test' + implementation 'org.glassfish.tyrus:tyrus-client:2.1.3' + implementation 'org.glassfish.tyrus:tyrus-container-grizzly-client:2.1.3' + implementation 'org.glassfish.tyrus.bundles:tyrus-standalone-client:2.1.3' + implementation 'jakarta.websocket:jakarta.websocket-api:2.1.1' + } configurations.all { diff --git a/src/main/java/LuckyVicky/backend/pachinko/controller/PachinkoController.java b/src/main/java/LuckyVicky/backend/pachinko/controller/PachinkoController.java index f789d5d..2942dfe 100644 --- a/src/main/java/LuckyVicky/backend/pachinko/controller/PachinkoController.java +++ b/src/main/java/LuckyVicky/backend/pachinko/controller/PachinkoController.java @@ -37,7 +37,7 @@ public class PachinkoController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PACHINKO_2001", description = "빠칭코 선택완료 칸 확인 성공"), }) @GetMapping("/chosen-squares") - public ApiResponse SelectedSquares( + public ApiResponse ChosenSquares( @AuthenticationPrincipal CustomUserDetails customUserDetails ) { User user = userService.findByUserName(customUserDetails.getUsername()); @@ -57,6 +57,18 @@ public ApiResponse SelectedSquares( PachinkoConverter.pachinkoChosenResDto(jewelsNumber, currentRound, meChosenSet, chosenSquares)); } + @Operation(summary = "빠칭코 선택된 칸 반환", description = "빠칭코에서 선택 완료된 칸 반환하는 메서드입니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PACHINKO_T_2000", description = "빠칭코 선택완료 칸 확인 성공"), + }) + @GetMapping("/selected-squares") + public ApiResponse> SelectedSquares() { + + Set chosenSquares = pachinkoService.viewSelectedSquares(); + + return ApiResponse.onSuccess(SuccessCode.PACHINKO_GET_SQUARES_SUCCESS, chosenSquares); + } + @Operation(summary = "빠칭코 첫 게임 시작", description = "빠칭코 첫 게임의 각 칸에 대한 보상을 정하는 메서드입니다.") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PACHINKO_2002", description = "빠칭코 첫 게임 시작 성공"), diff --git a/src/main/java/LuckyVicky/backend/pachinko/converter/PachinkoConverter.java b/src/main/java/LuckyVicky/backend/pachinko/converter/PachinkoConverter.java index 568ec46..a1fa9df 100644 --- a/src/main/java/LuckyVicky/backend/pachinko/converter/PachinkoConverter.java +++ b/src/main/java/LuckyVicky/backend/pachinko/converter/PachinkoConverter.java @@ -74,13 +74,11 @@ public static Pachinko savePachinko(Long currentRound, Integer squareNum, JewelT .build(); } - public static UserPachinko saveUserPachinko(Long currentRound, User user) { + public static UserPachinko saveUserPachinko(User user, Long currentRound, Integer squareNumber) { return UserPachinko.builder() .round(currentRound) .user(user) - .square1(0) - .square2(0) - .square3(0) + .square(squareNumber) .build(); } diff --git a/src/main/java/LuckyVicky/backend/pachinko/domain/UserPachinko.java b/src/main/java/LuckyVicky/backend/pachinko/domain/UserPachinko.java index 3008e70..c562591 100644 --- a/src/main/java/LuckyVicky/backend/pachinko/domain/UserPachinko.java +++ b/src/main/java/LuckyVicky/backend/pachinko/domain/UserPachinko.java @@ -23,46 +23,19 @@ @AllArgsConstructor @Table( name = "user_pachinko", - uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "round"}) + uniqueConstraints = @UniqueConstraint(columnNames = {"round", "square"}) ) public class UserPachinko { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - private Long round; // 게임 번호 + private Long round; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") private User user; - private Integer square1; + private Integer square; - private Integer square2; - - private Integer square3; - - public void setSquares(int square1, int square2, int square3) { - this.square1 = square1; - this.square2 = square2; - this.square3 = square3; - } - - public boolean addSquare(int squareNumber) { - if (square1 == null || square1 == 0) { - square1 = squareNumber; - return true; - } else if (square2 == null || square2 == 0) { - square2 = squareNumber; - return true; - } else if (square3 == null || square3 == 0) { - square3 = squareNumber; - return true; - } - return false; - } - - public boolean canSelectMore() { - return square3 == null || square3 == 0; // 세 번째 칸이 비어 있으면 선택 가능 - } } diff --git a/src/main/java/LuckyVicky/backend/pachinko/handler/PachinkoWebSocketHandler.java b/src/main/java/LuckyVicky/backend/pachinko/handler/PachinkoWebSocketHandler.java index 35945d6..81cdef8 100644 --- a/src/main/java/LuckyVicky/backend/pachinko/handler/PachinkoWebSocketHandler.java +++ b/src/main/java/LuckyVicky/backend/pachinko/handler/PachinkoWebSocketHandler.java @@ -1,5 +1,7 @@ package LuckyVicky.backend.pachinko.handler; +import static LuckyVicky.backend.pachinko.service.PachinkoService.PACHINKO_USER_MAX_SQUARES; + import LuckyVicky.backend.pachinko.service.PachinkoService; import LuckyVicky.backend.user.domain.User; import LuckyVicky.backend.user.jwt.JwtTokenUtils; @@ -9,14 +11,18 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; -import java.util.Objects; +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; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; +@Slf4j @Component @RequiredArgsConstructor public class PachinkoWebSocketHandler extends TextWebSocketHandler { @@ -24,43 +30,39 @@ public class PachinkoWebSocketHandler extends TextWebSocketHandler { private final PachinkoService pachinkoService; private final UserService userService; private final JwtTokenUtils jwtTokenUtils; - - // Json 데이터를 처리(파싱)하는 객체 private final ObjectMapper objectMapper = new ObjectMapper(); - - // 현재 연결된 모든 WebSocket 세션을 저장하는 리스트 private final List sessions = new ArrayList<>(); + private final ExecutorService virtualThreadExecutor = Executors.newVirtualThreadPerTaskExecutor(); + private final Semaphore messageLimiter = new Semaphore(200); // 동시에 200개만 처리 @Override public void afterConnectionEstablished(WebSocketSession session) { sessions.add(session); - System.out.println("새로운 사용자 접속"); - System.out.println("session 안의 요소 개수: " + sessions.size()); - for (WebSocketSession webSocketSession : sessions) { - System.out.println(webSocketSession.getId()); - } + logSessionConnected(); } @Override - protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { - String payload = message.getPayload(); - JsonNode node = objectMapper.readTree(payload); - - // JWT 토큰이 없는 경우 처리 (첫 번째 메시지에만 JWT 필수) - if (!session.getAttributes().containsKey("user") && node.has("token")) { - String token = node.get("token").asText(); - if (jwtTokenUtils.validateToken(token)) { - String username = jwtTokenUtils.getUsernameFromToken(token); - User user = userService.findByUserName(username); - session.getAttributes().put("user", user); - } else { - sendMessage(session, "JWT 검증 실패하여 연결 종료합니다."); - session.close(); - return; + protected void handleTextMessage(WebSocketSession session, TextMessage message) { + virtualThreadExecutor.submit(() -> { + try { + processIncomingMessage(session, message); + } catch (Exception e) { + e.printStackTrace(); } - } + }); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + sessions.remove(session); + } + + private void processIncomingMessage(WebSocketSession session, TextMessage message) throws IOException { + JsonNode node = objectMapper.readTree(message.getPayload()); - // 칸 선택 처리 (JWT 검증이 완료된 후) + if (!validateJwt(session, node)) { + return; + } if (!node.has("square")) { sendMessage(session, "Invalid message format: 'square' 필드에 값이 없습니다."); return; @@ -68,14 +70,12 @@ protected void handleTextMessage(WebSocketSession session, TextMessage message) int selectedSquare = node.get("square").asInt(); User user = (User) session.getAttributes().get("user"); - if (user == null) { sendMessage(session, "유저가 인증되지 않았습니다."); return; } long currentRound = pachinkoService.getCurrentRound(); - if (!validateUserState(session, user, currentRound)) { return; } @@ -83,28 +83,20 @@ protected void handleTextMessage(WebSocketSession session, TextMessage message) processSquareSelection(session, user, currentRound, selectedSquare); } - @Override - public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { - System.out.println("해당 세션 제거"); - sessions.remove(session); - } - - private void processSquareSelection(WebSocketSession session, User user, long currentRound, int selectedSquare) { - System.out.println(pachinkoService.viewSelectedSquares() + "핸들러에서 processSquareSelection 시작지점"); - - String result = pachinkoService.selectSquare(user, currentRound, selectedSquare); - System.out.println(pachinkoService.viewSelectedSquares() + "핸들러에서 processSquareSelection 시작지점"); - - if (Objects.equals(result, "정상적으로 선택 완료되었습니다.")) { - broadcastMessage(user.getNickname() + "가 " + selectedSquare + "을 선택했습니다."); - checkGameStatusAndCloseSessionsIfNeeded(); - } else if (Objects.equals(result, "다른 사용자가 이전에 선택한 칸입니다.")) { - sendMessage(session, selectedSquare + "번째 칸은 이미 다른 사용자에 의해 선택되었습니다."); - } else if (Objects.equals(result, "본인이 이전에 선택한 칸입니다.")) { - sendMessage(session, selectedSquare + "번째 칸은 본인이 이전에 선택한 칸입니다."); - } else { - sendMessage(session, "이미 3칸을 선택하셔서 더 이상 칸을 선택할 수 없습니다."); + private boolean validateJwt(WebSocketSession session, JsonNode node) throws IOException { + if (!session.getAttributes().containsKey("user") && node.has("token")) { + String token = node.get("token").asText(); + if (jwtTokenUtils.validateToken(token)) { + String username = jwtTokenUtils.getUsernameFromToken(token); + User user = userService.findByUserName(username); + session.getAttributes().put("user", user); + } else { + sendMessage(session, "JWT 검증 실패하여 연결 종료합니다."); + session.close(); + return false; + } } + return true; } private boolean validateUserState(WebSocketSession session, User user, long currentRound) { @@ -112,80 +104,78 @@ private boolean validateUserState(WebSocketSession session, User user, long curr sendMessage(session, "칸을 선택할때 필요한 보석이 부족합니다."); return false; } - if (!pachinkoService.canSelectMore(user, currentRound)) { - sendMessage(session, "이미 3칸을 선택하셔서 더 이상 칸을 선택할 수 없습니다."); + sendMessage(session, "이미 " + PACHINKO_USER_MAX_SQUARES + "칸을 선택하셔서 더 이상 칸을 선택할 수 없습니다."); return false; } - return true; } - private void sendMessage(WebSocketSession session, String message) { - try { - session.sendMessage(new TextMessage(message)); - } catch (IOException e) { - e.printStackTrace(); + private void processSquareSelection(WebSocketSession session, User user, long currentRound, int selectedSquare) { + String result = pachinkoService.selectSquare(user, currentRound, selectedSquare); + switch (result) { + case "정상적으로 선택 완료되었습니다." -> { + broadcastMessage(user.getNickname() + "가 " + selectedSquare + "을 선택했습니다."); + checkGameStatusAndCloseSessionsIfNeeded(); + } + case "다른 사용자가 이전에 선택한 칸입니다." -> sendMessage(session, selectedSquare + "번째 칸은 이미 다른 사용자에 의해 선택되었습니다."); + case "본인이 이전에 선택한 칸입니다." -> sendMessage(session, selectedSquare + "번째 칸은 본인이 이전에 선택한 칸입니다."); + case null, default -> sendMessage(session, "이미 3칸을 선택하셔서 더 이상 칸을 선택할 수 없습니다."); } } private void checkGameStatusAndCloseSessionsIfNeeded() { if (pachinkoService.isGameOver()) { - System.out.println("게임 끝남 확인. 보상 전달 시작"); broadcastMessage("해당 판이 종료되었습니다. 10초 후 새로운 판이 시작됩니다."); - Thread rewardThread = new Thread(() -> { + Thread.startVirtualThread(() -> { try { pachinkoService.giveRewards(); broadcastMessage("보상 전달이 완료되었습니다."); + pachinkoService.startNewRound(); + log.info("새로운 판 준비가 완료되었습니다."); } catch (Exception e) { - e.printStackTrace(); + log.error("보상 처리 중 예외 발생", e); } }); - Thread countdownThread = new Thread(() -> { + Thread.startVirtualThread(() -> { try { countdownAndNotifyPlayers(10); - startNewRoundAndNotifyPlayers(); + broadcastMessage("새로운 판이 시작됩니다."); } catch (InterruptedException e) { - e.printStackTrace(); + log.error("카운트다운 중 예외 발생", e); } }); - - // 두 작업을 비동기로 병렬 실행 - rewardThread.start(); - countdownThread.start(); } } private void countdownAndNotifyPlayers(int seconds) throws InterruptedException { for (int i = seconds; i > 0; i--) { broadcastMessage(i + "초 후에 새로운 게임이 시작됩니다."); - Thread.sleep(1000); + Thread.sleep(1000); // 가상 쓰레드라 문제 없음 } } - private void startNewRoundAndNotifyPlayers() { - broadcastMessage("새로운 판이 시작됩니다."); - pachinkoService.startNewRound(); - } - private void broadcastMessage(String message) { for (WebSocketSession session : sessions) { sendMessage(session, message); } } - /*public void endGameForAll() { - List sessionsCopy = new ArrayList<>(sessions); - for (WebSocketSession session : sessionsCopy) { - sendMessage(session, "해당 게임의 세션을 모두 종료합니다."); - try { - session.close(CloseStatus.NORMAL); - } catch (IOException e) { - e.printStackTrace(); - } + private void logSessionConnected() { + System.out.println("새로운 사용자 접속"); + System.out.println("session 안의 요소 개수: " + sessions.size()); + for (WebSocketSession webSocketSession : sessions) { + System.out.println(webSocketSession.getId()); + } + } + + private void sendMessage(WebSocketSession session, String message) { + try { + session.sendMessage(new TextMessage(message)); + } catch (IOException e) { + e.printStackTrace(); } - sessions.clear(); - }*/ + } } diff --git a/src/main/java/LuckyVicky/backend/pachinko/repository/UserPachinkoRepository.java b/src/main/java/LuckyVicky/backend/pachinko/repository/UserPachinkoRepository.java index c7300c9..b6dd497 100644 --- a/src/main/java/LuckyVicky/backend/pachinko/repository/UserPachinkoRepository.java +++ b/src/main/java/LuckyVicky/backend/pachinko/repository/UserPachinkoRepository.java @@ -2,11 +2,8 @@ import LuckyVicky.backend.pachinko.domain.UserPachinko; import LuckyVicky.backend.user.domain.User; -import jakarta.persistence.LockModeType; import java.util.List; -import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -14,11 +11,19 @@ @Repository public interface UserPachinkoRepository extends JpaRepository { - Optional findByUserAndRound(User user, Long round); + List findByUserAndRound(User user, Long round); - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT u FROM UserPachinko u WHERE u.user = :user AND u.round = :round") - Optional findByUserAndRoundForUpdate(@Param("user") User user, @Param("round") Long round); + long countByUserAndRound(User user, Long round); + + boolean existsByUserAndRoundAndSquare(User user, Long round, Integer square); + + @Query(""" + SELECT up FROM UserPachinko up + JOIN FETCH up.user u + LEFT JOIN FETCH u.deviceTokenList + WHERE up.round = :round + """) + List findByRoundWithUserAndDeviceTokens(@Param("round") Long round); List findByRound(Long round); diff --git a/src/main/java/LuckyVicky/backend/pachinko/service/PachinkoLoadTestWithVerification.java b/src/main/java/LuckyVicky/backend/pachinko/service/PachinkoLoadTestWithVerification.java new file mode 100644 index 0000000..b0b1afe --- /dev/null +++ b/src/main/java/LuckyVicky/backend/pachinko/service/PachinkoLoadTestWithVerification.java @@ -0,0 +1,150 @@ +package LuckyVicky.backend.pachinko.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.websocket.ContainerProvider; +import jakarta.websocket.Endpoint; +import jakarta.websocket.EndpointConfig; +import jakarta.websocket.Session; +import jakarta.websocket.WebSocketContainer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.util.Scanner; +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 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()); + + CountDownLatch latch = new CountDownLatch(USERS_PER_SQUARE * TOTAL_SQUARES); + + for (int square = 1; square <= TOTAL_SQUARES; square++) { + final int finalSquare = square; + for (int i = 0; i < USERS_PER_SQUARE; i++) { + final int userIndex = (square - 1) * USERS_PER_SQUARE + i; + executor.submit(() -> { + try { + String token = getTokenForUser(userIndex); + connectAndSendWebSocket(token, finalSquare); + } catch (Exception e) { + System.err.println("에러: " + e.getMessage()); + } finally { + latch.countDown(); + } + }); + } + } + + latch.await(); + executor.shutdown(); + System.out.println("make user finish"); + + // 결과 검증 + Thread.sleep(2000); // 데이터 반영 대기 + verifySelectedSquares(); + } + + 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"); + con.setRequestProperty("Content-Type", "application/json"); + con.setDoOutput(true); + + String payload = String.format(""" + { + "email": "testuser%d@example.com", + "username": "user%d", + "provider": "test", + "deviceToken": "test" + } + """, userNum, userNum); + + try (OutputStream os = con.getOutputStream()) { + os.write(payload.getBytes()); + } + + if (con.getResponseCode() == 200) { + try (Scanner scanner = new Scanner(con.getInputStream()).useDelimiter("\\A")) { + 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; + + } + } else { + throw new RuntimeException("토큰 요청 실패: " + con.getResponseCode()); + } + } + + private static void connectAndSendWebSocket(String token, int square) throws Exception { + WebSocketContainer container = ContainerProvider.getWebSocketContainer(); + Session session = container.connectToServer(new Endpoint() { + @Override + public void onOpen(Session session, EndpointConfig config) { + try { + String json = String.format(""" + { + "token": "%s", + "square": %d + } + """, token, square); + session.getBasicRemote().sendText(json); + } catch (IOException e) { + e.printStackTrace(); + } + } + }, URI.create(WS_URL)); + Thread.sleep(200); // 메시지 전송 후 약간 대기 + session.close(); + } + + private static void verifySelectedSquares() throws Exception { + URL url = new URL(VERIFY_URL); + HttpURLConnection con = (HttpURLConnection) url.openConnection(); + con.setRequestMethod("GET"); + + 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); + } else { + System.err.println("테스트 실패: 선택된 칸 수 = " + squares.length); + } + } + } else { + System.err.println(" 확인 API 호출 실패: " + con.getResponseCode()); + } + } +} + diff --git a/src/main/java/LuckyVicky/backend/pachinko/service/PachinkoService.java b/src/main/java/LuckyVicky/backend/pachinko/service/PachinkoService.java index 54a9da5..b1c204d 100644 --- a/src/main/java/LuckyVicky/backend/pachinko/service/PachinkoService.java +++ b/src/main/java/LuckyVicky/backend/pachinko/service/PachinkoService.java @@ -22,20 +22,21 @@ import LuckyVicky.backend.user.repository.UserRepository; import jakarta.annotation.PostConstruct; import jakarta.transaction.Transactional; -import java.io.IOException; import java.security.SecureRandom; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +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.Recover; import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; @@ -43,8 +44,9 @@ @Service @RequiredArgsConstructor public class PachinkoService { - private static final int TOTAL_PACHINKO_SQUARE_COUNT = 36; - private static final int MIN_PACHINKO_SQUARE_NUMBER = 1; + private static final int PACHINKO_TOTAL_SQUARE_COUNT = 36; + private static final int PACHINKO_MIN_SQUARE_NUMBER = 1; + public static final int PACHINKO_USER_MAX_SQUARES = 10; private static final String REWARD_S1 = "S1"; private static final String REWARD_A1 = "A1"; private static final String REWARD_B2 = "B2"; @@ -54,7 +56,7 @@ public class PachinkoService { private static final int PACHINKO_NEED_JEWEL_COUNT = 1; private final PachinkoRepository pachinkoRepository; - private final UserPachinkoRepository userpachinkoRepository; + private final UserPachinkoRepository userPachinkoRepository; private final PachinkoRewardRepository pachinkoRewardRepository; private final UserJewelRepository userJewelRepository; private final UserRepository userRepository; @@ -62,7 +64,7 @@ public class PachinkoService { private final FcmService fcmService; @Getter - private final Set selectedSquares = Collections.newSetFromMap(new ConcurrentHashMap<>()); + private final Set selectedSquares = ConcurrentHashMap.newKeySet(); public Set viewSelectedSquares() { // 읽기 전용 뷰 반환 return Collections.unmodifiableSet(selectedSquares); @@ -78,27 +80,11 @@ public void startFirstRound() { @Transactional public List getMeChosen(User user) { - UserPachinko userPachinko = userpachinkoRepository.findByUserAndRound(user, currentRound) - .orElse(UserPachinko.builder() - .round(currentRound) - .user(user) - .square1(0) - .square2(0) - .square3(0) - .build()); - if (userPachinko.getSquare1() == 0) { - return Collections.nCopies(3, 0); - } else { - List meChosen = new ArrayList<>(Collections.nCopies(3, 0)); - meChosen.set(0, userPachinko.getSquare1()); - if (userPachinko.getSquare2() != 0) { - meChosen.set(1, userPachinko.getSquare2()); - } - if (userPachinko.getSquare3() != 0) { - meChosen.set(2, userPachinko.getSquare3()); - } - return meChosen; - } + List selected = userPachinkoRepository.findByUserAndRound(user, currentRound); + return selected.stream() + .map(UserPachinko::getSquare) + .sorted() + .collect(Collectors.toList()); } @Transactional @@ -123,26 +109,27 @@ public boolean noMoreJewel(User user) { @Transactional public boolean canSelectMore(User user, Long round) { - // Optional로 조회하여 값이 없으면 true 반환, 있으면 조건에 맞게 처리 - return userpachinkoRepository.findByUserAndRound(user, round) - .map(UserPachinko::canSelectMore) // 존재할 때 조건에 맞게 처리 - .orElse(true); // 존재하지 않으면 true 반환 + return userPachinkoRepository.countByUserAndRound(user, round) < PACHINKO_USER_MAX_SQUARES; } @Transactional @Retryable( value = DataIntegrityViolationException.class, - maxAttempts = 3, + maxAttempts = 2, backoff = @Backoff(delay = 100, multiplier = 2) ) public String selectSquare(User user, long currentRound, int squareNumber) { - // 1. 칸 번호 유효성 검증 + // 칸 번호 유효성 검증 validateSquareNumber(squareNumber); - // 2. 이미 선택된 칸인지 확인 + // 이미 선택된 칸인지 확인 if (selectedSquares.contains(squareNumber)) { - log.info("{} 이미 {}가 존재합니다.", selectedSquares, squareNumber); - if (isUserSelected(user, currentRound, squareNumber)) { + log.info("{} 이미 {}가 선택되었습니다.", selectedSquares, squareNumber); + + boolean userAlreadySelected = userPachinkoRepository.existsByUserAndRoundAndSquare(user, currentRound, + squareNumber); + + if (userAlreadySelected) { log.info("본인이 이전에 선택한 칸입니다."); return "본인이 이전에 선택한 칸입니다."; } else { @@ -151,47 +138,22 @@ public String selectSquare(User user, long currentRound, int squareNumber) { } } - // 3. 사용자 Pachinko 상태 조회 및 초기화 - UserPachinko userPachinko = userpachinkoRepository.findByUserAndRoundForUpdate(user, currentRound) - .orElseGet(() -> initializeUserPachinko(user, currentRound)); - - // 4. 칸 추가 로직 & 더 이상 선택할 수 없는 경우 처리 - if (!userPachinko.addSquare(squareNumber)) { - log.info("이미 세 칸을 선택하셨습니다."); - return "이미 세 개의 칸을 선택하셨습니다."; - } - - // 5. 사용자 Pachinko 상태 저장 - userpachinkoRepository.save(userPachinko); + // 사용자 Pachinko 상태 저장 + userPachinkoRepository.save(PachinkoConverter.saveUserPachinko(user, currentRound, squareNumber)); log.info("user pachinko에 선택한 칸인 {}을 저장했습니다.", squareNumber); - // 6. 보석 차감 로직 - deductUserJewel(user); - log.info("빠칭코 칸 선택을 위해 B급 보석 하나를 지불하여 DB에서 보석을 차감했습니다."); - - // 7. 선택한 칸을 set에 추가 + // 선택한 칸을 set에 추가 addSelectedSquare(squareNumber); log.info("선택한 칸을 set에 삽입했습니다. 변경된 set: {}", selectedSquares); - return "정상적으로 선택 완료되었습니다."; - } - - @Recover - public String recover(DataIntegrityViolationException e, User user, long currentRound, int squareNumber) { - log.warn("재시도 후에도 UserPachinko 엔티티 중복 생성 시도: {}", e.getMessage()); - return "이미 다른 트랜잭션에서 생성된 사용자 데이터입니다."; - } + // 보석 차감 + deductUserJewel(user); + log.info("빠칭코 칸 선택을 위해 B급 보석 하나를 지불하여 DB에서 보석을 차감했습니다."); - private boolean isUserSelected(User user, long currentRound, int squareNumber) { - UserPachinko userPachinko = userpachinkoRepository.findByUserAndRound(user, currentRound) - .orElseGet(() -> initializeUserPachinko(user, currentRound)); - Integer s1 = userPachinko.getSquare1(); - Integer s2 = userPachinko.getSquare2(); - Integer s3 = userPachinko.getSquare3(); - return (squareNumber == s1 || squareNumber == s2 || squareNumber == s3); + return "정상적으로 선택 완료되었습니다."; } - public synchronized void addSelectedSquare(int square) { + public void addSelectedSquare(int square) { selectedSquares.add(square); } @@ -202,42 +164,34 @@ private void deductUserJewel(User user) { userJewelRepository.save(userJewel); } - private UserPachinko initializeUserPachinko(User user, long currentRound) { - UserPachinko newUserPachinko = PachinkoConverter.saveUserPachinko(currentRound, user); - return userpachinkoRepository.save(newUserPachinko); - } - private void validateSquareNumber(int squareNumber) { - if (squareNumber < MIN_PACHINKO_SQUARE_NUMBER || squareNumber > TOTAL_PACHINKO_SQUARE_COUNT) { + if (squareNumber < PACHINKO_MIN_SQUARE_NUMBER || squareNumber > PACHINKO_TOTAL_SQUARE_COUNT) { throw new GeneralException(ErrorCode.PACHINKO_OUT_OF_BOUND); } } public boolean isGameOver() { System.out.println("모든 칸 선택 되었나 확인중"); - return (selectedSquares.size() == TOTAL_PACHINKO_SQUARE_COUNT); + return (selectedSquares.size() == PACHINKO_TOTAL_SQUARE_COUNT); } @Transactional - public void giveRewards() throws IOException { + public void giveRewards() { System.out.println("보상 전달 시작"); - List userPachinkoList = userpachinkoRepository.findByRound(currentRound); + List userPachinkoList = userPachinkoRepository.findByRoundWithUserAndDeviceTokens(currentRound); + + ExecutorService virtualThreadExecutor = Executors.newVirtualThreadPerTaskExecutor(); + List> futures = new ArrayList<>(); for (UserPachinko userPachinko : userPachinkoList) { - User user = userPachinko.getUser(); - user.updatePreviousPachinkoRound(currentRound); - userRepository.save(user); - - List squares = new ArrayList<>(); - squares.add(userPachinko.getSquare1()); - squares.add(userPachinko.getSquare2()); - squares.add(userPachinko.getSquare3()); - - for (int i = 0; i < 3; i++) { - if (squares.get(i) > 0) { - int sq = squares.get(i); - // 해당 칸에 대한 보상 알아내기 - System.out.println("보상찾기 - squares.get(i): " + squares.get(i)); + futures.add(virtualThreadExecutor.submit(() -> { + try { + User user = userPachinko.getUser(); + user.updatePreviousPachinkoRound(currentRound); + userRepository.save(user); + + int sq = userPachinko.getSquare(); + Pachinko pa = pachinkoRepository.findByRoundAndSquare(currentRound, sq) .orElseThrow(() -> new GeneralException(ErrorCode.BAD_REQUEST)); @@ -253,18 +207,36 @@ public void giveRewards() throws IOException { displayBoardService.addPachinkoSJewelMessage(user); } - List userDeviceTokens = user.getDeviceTokenList(); - for (UserDeviceToken userDeviceToken : userDeviceTokens) { - FcmSimpleReqDto requestDTO = FcmConverter.toFcmSimpleReqDto(userDeviceToken.getDeviceToken(), - Constant.FCM_PACHINKO_GAME_FINISH_TITLE, Constant.FCM_PACHINKO_GAME_FINISH_BODY); - fcmService.sendMessageTo(requestDTO); + for (UserDeviceToken token : user.getDeviceTokenList()) { + FcmSimpleReqDto dto = FcmConverter.toFcmSimpleReqDto( + token.getDeviceToken(), + Constant.FCM_PACHINKO_GAME_FINISH_TITLE, + Constant.FCM_PACHINKO_GAME_FINISH_BODY + ); + fcmService.sendMessageTo(dto); } + + System.out.println("보상 전달 완료"); + } catch (Exception e) { + log.error("보상 작업 처리 중 예외 발생", e); } + })); + } + + // 모든 작업 완료 대기 + for (Future future : futures) { + try { + future.get(); + } catch (Exception e) { + log.error("보상 작업 처리 중 예외 발생", e); } - System.out.println("보상 전달 완료"); } + + virtualThreadExecutor.shutdown(); + System.out.println("모든 보상 전달 완료"); } + public void assignRewardsToSquares(Long currentRound) { // 보상 항목들을 리스트에 추가 List rewards = new ArrayList<>(); @@ -279,7 +251,7 @@ public void assignRewardsToSquares(Long currentRound) { .orElseThrow(() -> new GeneralException(ErrorCode.BAD_REQUEST)); int fSquareCount = - TOTAL_PACHINKO_SQUARE_COUNT - (s1.getSquareCount() + a1.getSquareCount() + b2.getSquareCount() + PACHINKO_TOTAL_SQUARE_COUNT - (s1.getSquareCount() + a1.getSquareCount() + b2.getSquareCount() + b1.getSquareCount()); for (int i = 0; i < s1.getSquareCount(); i++) { @@ -302,24 +274,30 @@ public void assignRewardsToSquares(Long currentRound) { Collections.shuffle(rewards, secureRandom); // db에 넣기 - for (int i = 0; i < TOTAL_PACHINKO_SQUARE_COUNT; i++) { + for (int i = 0; i < PACHINKO_TOTAL_SQUARE_COUNT; i++) { JewelType jewelType; int jewelNum; - if (Objects.equals(rewards.get(i), REWARD_S1)) { - jewelType = JewelType.S; - jewelNum = 1; - } else if (Objects.equals(rewards.get(i), REWARD_A1)) { - jewelType = JewelType.A; - jewelNum = 1; - } else if (Objects.equals(rewards.get(i), REWARD_B2)) { - jewelType = JewelType.B; - jewelNum = 2; - } else if (Objects.equals(rewards.get(i), REWARD_B1)) { - jewelType = JewelType.B; - jewelNum = 1; - } else { - jewelType = JewelType.F; - jewelNum = 0; + switch (rewards.get(i)) { + case REWARD_S1 -> { + jewelType = JewelType.S; + jewelNum = 1; + } + case REWARD_A1 -> { + jewelType = JewelType.A; + jewelNum = 1; + } + case REWARD_B2 -> { + jewelType = JewelType.B; + jewelNum = 2; + } + case REWARD_B1 -> { + jewelType = JewelType.B; + jewelNum = 1; + } + case null, default -> { + jewelType = JewelType.F; + jewelNum = 0; + } } Pachinko newPachinco = PachinkoConverter.savePachinko(currentRound, i + 1, jewelType, jewelNum); @@ -335,34 +313,31 @@ public List getRewards(User user) { throw new GeneralException(ErrorCode.PACHINKO_NO_PREVIOUS_ROUND); } - UserPachinko userPachinko = userpachinkoRepository.findByUserAndRound(user, round) - .orElseThrow(() -> new GeneralException(ErrorCode.USER_PACHINKO_NOT_FOUND)); - - List squares = new ArrayList<>(); - squares.add(userPachinko.getSquare1()); - squares.add(userPachinko.getSquare2()); - squares.add(userPachinko.getSquare3()); - - List jewelsNum = new ArrayList<>(Collections.nCopies(3, 0L)); - - for (int i = 0; i < 3; i++) { - if (squares.get(i) > 0) { - int sq = squares.get(i); - Pachinko pa = pachinkoRepository.findByRoundAndSquare(round, sq) - .orElseThrow(() -> new GeneralException(ErrorCode.BAD_REQUEST)); - if (pa.getJewelType() == JewelType.S) { - jewelsNum.set(0, jewelsNum.get(0) + pa.getJewelNum()); - } else if (pa.getJewelType() == JewelType.A) { - jewelsNum.set(1, jewelsNum.get(1) + pa.getJewelNum()); - } else if (pa.getJewelType() == JewelType.B) { - jewelsNum.set(2, jewelsNum.get(2) + pa.getJewelNum()); - } + // 유저가 해당 라운드에 선택한 모든 square 조회 + List selections = userPachinkoRepository.findByUserAndRound(user, round); + if (selections.isEmpty()) { + throw new GeneralException(ErrorCode.USER_PACHINKO_NOT_FOUND); + } + + // A, B, S 보석 수를 담을 리스트 + List jewelsNum = new ArrayList<>(List.of(0L, 0L, 0L)); // [S, A, B] + + for (UserPachinko selection : selections) { + int sq = selection.getSquare(); + Pachinko pa = pachinkoRepository.findByRoundAndSquare(round, sq) + .orElseThrow(() -> new GeneralException(ErrorCode.BAD_REQUEST)); + + switch (pa.getJewelType()) { + case S -> jewelsNum.set(0, jewelsNum.get(0) + pa.getJewelNum()); + case A -> jewelsNum.set(1, jewelsNum.get(1) + pa.getJewelNum()); + case B -> jewelsNum.set(2, jewelsNum.get(2) + pa.getJewelNum()); } } return jewelsNum; } + public List getPreviousPachinkoRewards(Long round) { return pachinkoRepository.findByRound(round); } @@ -370,18 +345,16 @@ public List getPreviousPachinkoRewards(Long round) { @PostConstruct public Long updateSelectedSquaresSet() { - if (selectedSquares.size() == TOTAL_PACHINKO_SQUARE_COUNT) { - currentRound = userpachinkoRepository.findCurrentRound() + 1; + if (selectedSquares.size() == PACHINKO_TOTAL_SQUARE_COUNT) { + currentRound = userPachinkoRepository.findCurrentRound() + 1; selectedSquares.clear(); } else { selectedSquares.clear(); - currentRound = userpachinkoRepository.findCurrentRound(); + currentRound = userPachinkoRepository.findCurrentRound(); - List userPachinkoList = userpachinkoRepository.findByRound(currentRound); + List userPachinkoList = userPachinkoRepository.findByRound(currentRound); for (UserPachinko userPachinko : userPachinkoList) { - selectedSquares.add(userPachinko.getSquare1()); - selectedSquares.add(userPachinko.getSquare2()); - selectedSquares.add(userPachinko.getSquare3()); + selectedSquares.add(userPachinko.getSquare()); } selectedSquares.remove(0); } diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 089b8fd..d4c0124 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -12,7 +12,15 @@ + + + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + + + + +