diff --git a/.github/workflows/dev-deploy.yml b/.github/workflows/dev-deploy.yml index 0f02c3ef..6a577970 100644 --- a/.github/workflows/dev-deploy.yml +++ b/.github/workflows/dev-deploy.yml @@ -1,20 +1,17 @@ name: 변경사항을 개발 서버에 배포한다 + on: - workflow_dispatch: push: - branches: [ "dev" ] + branches: [ dev ] + workflow_dispatch: + permissions: contents: read - -env: - JAR_NAME: snackgame-server.jar - JAR_DIRECTORY: /home/ubuntu/snackgame + packages: write jobs: - deploy: - runs-on: dev - environment: - name: dev + build-and-push: + runs-on: ubuntu-latest steps: - name: Get token from Submodule Reader uses: actions/create-github-app-token@v1 @@ -30,102 +27,48 @@ jobs: submodules: true token: ${{ steps.app_token.outputs.token }} - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 - - - name: Create bootjar - uses: gradle/gradle-build-action@v3 + - name: Set up JDK 17 + uses: actions/setup-java@v4 with: - arguments: bootjar + java-version: '17' + distribution: corretto - - name: Copy jar - shell: bash {0} - run: | - mkdir $JAR_DIRECTORY - cp ./build/libs/$JAR_NAME $JAR_DIRECTORY/$JAR_NAME - - - name: 현재 사용중인 어플리케이션 포트 확인 - shell: bash {0} - run: | - PORT_A_PID=$(lsof -ti:${{ vars.APPLICATION_PORT_A }}) - PORT_B_PID=$(lsof -ti:${{ vars.APPLICATION_PORT_B }}) - if [ -n "$PORT_A_PID" ] && [ -n "$PORT_B_PID" ]; then - echo "::error title=배포 실패::$PORT_A_PID, $PORT_B_PID 두 포트가 모두 사용중입니다"; - exit 1; - elif [ -n "$PORT_A_PID" ]; then - echo "BLUE_PORT=${{ vars.APPLICATION_PORT_A }}" >> "$GITHUB_ENV" - echo "GREEN_PORT=${{ vars.APPLICATION_PORT_B }}" >> "$GITHUB_ENV" - elif [ -n "$PORT_B_PID" ]; then - echo "BLUE_PORT=${{ vars.APPLICATION_PORT_B }}" >> "$GITHUB_ENV" - echo "GREEN_PORT=${{ vars.APPLICATION_PORT_A }}" >> "$GITHUB_ENV" - else - echo "BLUE_PORT=${{ vars.APPLICATION_PORT_A }}" >> "$GITHUB_ENV" - echo "GREEN_PORT=${{ vars.APPLICATION_PORT_B }}" >> "$GITHUB_ENV" - fi + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 - - name: Download Datadog Java Agent - working-directory: ${{ env.JAR_DIRECTORY }} - run: | - wget -O dd-java-agent.jar 'https://dtdg.co/latest-java-tracer' + - name: SHA 앞 7자리 추출 + id: sha + run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT - - name: 그린 어플리케이션 실행 + - name: Jib으로 이미지 빌드 및 GHCR Push env: - RUNNER_TRACKING_ID: "" - shell: bash - working-directory: ${{ env.JAR_DIRECTORY }} - run: | - nohup java \ - -Dserver.port=$GREEN_PORT \ - -Dspring.profiles.active=${{ vars.ENVIRONMENT_NAME }} \ - -DACCESS_TOKEN_SECRET_KEY=${{ secrets.ACCESS_TOKEN_SECRET_KEY }} \ - -DACCESS_TOKEN_EXPIRY_DAYS=${{ secrets.ACCESS_TOKEN_EXPIRY_DAYS }} \ - -DREFRESH_TOKEN_SECRET_KEY=${{ secrets.REFRESH_TOKEN_SECRET_KEY }} \ - -DREFRESH_TOKEN_EXPIRY_DAYS=${{ secrets.REFRESH_TOKEN_EXPIRY_DAYS }} \ - -DDB_URL=${{ secrets.DB_URL }} \ - -DDB_USERNAME=${{ secrets.DB_USERNAME }} \ - -DDB_PASSWORD=${{ secrets.DB_PASSWORD }} \ - -javaagent:dd-java-agent.jar \ - -Ddd.profiling.enabled=true \ - -XX:FlightRecorderOptions=stackdepth=256 \ - -Ddd.logs.injection=true \ - -Ddd.appsec.enabled=true \ - -Ddd.iast.enabled=true \ - -Ddd.service=snackgame \ - -Ddd.env=${{ vars.ENVIRONMENT_NAME }} \ - -jar $JAR_NAME > ~/snackgame-server.log & - - - name: 그린 어플리케이션이 접속 가능할 때까지 기다린다 - shell: bash {0} - run: | - PROCESS_ID="$(lsof -i:$GREEN_PORT -t)" - while [ "$(curl -o /dev/null -s -w %{http_code} localhost:$GREEN_PORT/rankings/1?by=BEST_SCORE)" != 200 ] - do - if [ ! -e "/proc/$PROCESS_ID" ]; then - echo "::error title=배포 실패::블루 어플리케이션으로 롤백합니다."; - exit 1; - fi - echo "새로운 어플리케이션을 띄우는 중입니다."; - sleep 5; - done - - - name: 리버스 프록시 설정 변경 - working-directory: ${{ env.JAR_DIRECTORY }} - shell: bash {0} - run: | - echo "proxy_pass http://localhost:$GREEN_PORT;" > port.inc; - sudo nginx -s reload; + GHCR_USERNAME: ${{ github.actor }} + GHCR_PASSWORD: ${{ secrets.GITHUB_TOKEN }} + run: ./gradlew jib -Djib.to.tags=dev,${{ steps.sha.outputs.short-sha }} - - name: 블루 어플리케이션 종료 - shell: bash {0} - run: | - PROCESS_ID="$(lsof -i:$BLUE_PORT -t)" - if [ -n "$PROCESS_ID" ]; then - sudo kill -15 $PROCESS_ID - sleep 5 - if ps -p $PROCESS_ID > /dev/null; then - echo "프로세스가 아직 살아있음. 강제 종료합니다." - sudo kill -9 $PROCESS_ID - else - echo "구동중인 애플리케이션을 종료했습니다. (pid : $PROCESS_ID)\n" - fi - fi + deploy: + needs: build-and-push + runs-on: ubuntu-latest + environment: dev + steps: + - name: snackgame-02 dev 배포 + uses: appleboy/ssh-action@v1 + env: + ACCESS_TOKEN_SECRET_KEY: ${{ secrets.ACCESS_TOKEN_SECRET_KEY }} + REFRESH_TOKEN_SECRET_KEY: ${{ secrets.REFRESH_TOKEN_SECRET_KEY }} + DB_URL: ${{ secrets.DB_URL }} + DB_USERNAME: ${{ secrets.DB_USERNAME }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + with: + host: ${{ secrets.SSH_HOST_02 }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script_stop: true + envs: ACCESS_TOKEN_SECRET_KEY,REFRESH_TOKEN_SECRET_KEY,DB_URL,DB_USERNAME,DB_PASSWORD + script: | + cd ~/snackgame-server/repo + git fetch origin dev + git checkout dev + git pull origin dev + chmod +x scripts/deploy-dev.sh + scripts/deploy-dev.sh \ No newline at end of file diff --git a/.github/workflows/production-deploy.yml b/.github/workflows/production-deploy.yml index 775c9ce2..cda77af7 100644 --- a/.github/workflows/production-deploy.yml +++ b/.github/workflows/production-deploy.yml @@ -1,21 +1,17 @@ name: 변경사항을 운영 서버에 배포한다 on: - workflow_dispatch: push: - branches: [ "main" ] + branches: [ main ] + workflow_dispatch: + permissions: contents: read - -env: - JAR_NAME: snackgame-server.jar - JAR_DIRECTORY: /home/ubuntu/snackgame + packages: write jobs: - deploy: - runs-on: production - environment: - name: production + build-and-push: + runs-on: ubuntu-latest steps: - name: Get token from Submodule Reader uses: actions/create-github-app-token@v1 @@ -31,103 +27,81 @@ jobs: submodules: true token: ${{ steps.app_token.outputs.token }} - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 - - - name: Build - uses: gradle/gradle-build-action@v3 + - name: Set up JDK 17 + uses: actions/setup-java@v4 with: - arguments: build + java-version: '17' + distribution: corretto - - name: Copy jar - shell: bash {0} - run: | - mkdir $JAR_DIRECTORY - cp ./build/libs/$JAR_NAME $JAR_DIRECTORY/$JAR_NAME - - - name: 현재 사용중인 어플리케이션 포트 확인 - shell: bash {0} - run: | - PORT_A_PID=$(lsof -ti:${{ vars.APPLICATION_PORT_A }}) - PORT_B_PID=$(lsof -ti:${{ vars.APPLICATION_PORT_B }}) - if [ -n "$PORT_A_PID" ] && [ -n "$PORT_B_PID" ]; then - echo "::error title=배포 실패::$PORT_A_PID, $PORT_B_PID 두 포트가 모두 사용중입니다"; - exit 1; - elif [ -n "$PORT_A_PID" ]; then - echo "BLUE_PORT=${{ vars.APPLICATION_PORT_A }}" >> "$GITHUB_ENV" - echo "GREEN_PORT=${{ vars.APPLICATION_PORT_B }}" >> "$GITHUB_ENV" - elif [ -n "$PORT_B_PID" ]; then - echo "BLUE_PORT=${{ vars.APPLICATION_PORT_B }}" >> "$GITHUB_ENV" - echo "GREEN_PORT=${{ vars.APPLICATION_PORT_A }}" >> "$GITHUB_ENV" - else - echo "BLUE_PORT=${{ vars.APPLICATION_PORT_A }}" >> "$GITHUB_ENV" - echo "GREEN_PORT=${{ vars.APPLICATION_PORT_B }}" >> "$GITHUB_ENV" - fi + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 - - name: Download Datadog Java Agent - working-directory: ${{ env.JAR_DIRECTORY }} - run: | - wget -O dd-java-agent.jar 'https://dtdg.co/latest-java-tracer' + - name: SHA 앞 7자리 추출 + id: sha + run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT - - name: 그린 어플리케이션 실행 + - name: Jib으로 이미지 빌드 및 GHCR Push env: - RUNNER_TRACKING_ID: "" - shell: bash - working-directory: ${{ env.JAR_DIRECTORY }} - run: | - nohup java \ - -Dserver.port=$GREEN_PORT \ - -Dspring.profiles.active=${{ vars.ENVIRONMENT_NAME }} \ - -DACCESS_TOKEN_SECRET_KEY=${{ secrets.ACCESS_TOKEN_SECRET_KEY }} \ - -DACCESS_TOKEN_EXPIRY_DAYS=${{ secrets.ACCESS_TOKEN_EXPIRY_DAYS }} \ - -DREFRESH_TOKEN_SECRET_KEY=${{ secrets.REFRESH_TOKEN_SECRET_KEY }} \ - -DREFRESH_TOKEN_EXPIRY_DAYS=${{ secrets.REFRESH_TOKEN_EXPIRY_DAYS }} \ - -DDB_URL=${{ secrets.DB_URL }} \ - -DDB_USERNAME=${{ secrets.DB_USERNAME }} \ - -DDB_PASSWORD=${{ secrets.DB_PASSWORD }} \ - -javaagent:dd-java-agent.jar \ - -Ddd.profiling.enabled=true \ - -XX:FlightRecorderOptions=stackdepth=256 \ - -Ddd.logs.injection=true \ - -Ddd.appsec.enabled=true \ - -Ddd.iast.enabled=true \ - -Ddd.service=snackgame \ - -Ddd.env=${{ vars.ENVIRONMENT_NAME }} \ - -jar $JAR_NAME > ~/snackgame-server.log & - - - name: 그린 어플리케이션이 접속 가능할 때까지 기다린다 - shell: bash {0} - run: | - PROCESS_ID="$(lsof -i:$GREEN_PORT -t)" - while [ "$(curl -o /dev/null -s -w %{http_code} localhost:$GREEN_PORT/rankings/1?by=BEST_SCORE)" != 200 ] - do - if [ ! -e /proc/$PROCESS_ID ]; then - echo "::error title=배포 실패::블루 어플리케이션으로 롤백합니다."; - exit 1; - fi - echo "새로운 어플리케이션을 띄우는 중입니다."; - sleep 5; - done - - - name: 리버스 프록시 설정 변경 - working-directory: ${{ env.JAR_DIRECTORY }} - shell: bash {0} - run: | - echo "proxy_pass http://localhost:$GREEN_PORT;" > port.inc; - sudo nginx -s reload; + GHCR_USERNAME: ${{ github.actor }} + GHCR_PASSWORD: ${{ secrets.GITHUB_TOKEN }} + run: ./gradlew jib -Djib.to.tags=latest,${{ steps.sha.outputs.short-sha }} - - name: 블루 어플리케이션 종료 - shell: bash {0} - run: | - PROCESS_ID="$(lsof -i:$BLUE_PORT -t)" - if [ -n "$PROCESS_ID" ]; then - sudo kill -15 $PROCESS_ID - sleep 5 - if ps -p $PROCESS_ID > /dev/null; then - echo "프로세스가 아직 살아있음. 강제 종료합니다." - sudo kill -9 $PROCESS_ID - else - echo "구동중인 애플리케이션을 종료했습니다. (pid : $PROCESS_ID)\n" - fi - fi + deploy-02: + needs: build-and-push + runs-on: ubuntu-latest + environment: production + steps: + - name: snackgame-02 배포 + uses: appleboy/ssh-action@v1 + env: + NLB_ID: ${{ secrets.NLB_ID }} + ACCESS_TOKEN_SECRET_KEY: ${{ secrets.ACCESS_TOKEN_SECRET_KEY }} + REFRESH_TOKEN_SECRET_KEY: ${{ secrets.REFRESH_TOKEN_SECRET_KEY }} + DB_URL: ${{ secrets.DB_URL }} + DB_USERNAME: ${{ secrets.DB_USERNAME }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + with: + host: ${{ secrets.SSH_HOST_02 }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script_stop: true + envs: NLB_ID,ACCESS_TOKEN_SECRET_KEY,REFRESH_TOKEN_SECRET_KEY,DB_URL,DB_USERNAME,DB_PASSWORD + script: | + cd ~/snackgame-server/repo + git fetch origin main + git checkout main + git pull origin main + chmod +x scripts/deploy-rolling.sh + scripts/deploy-rolling.sh \ + ${{ secrets.NLB_BACKEND_02_HTTP }} \ + ${{ secrets.NLB_BACKEND_02_HTTPS }} + deploy-01: + needs: deploy-02 + runs-on: ubuntu-latest + environment: production + steps: + - name: snackgame-01 배포 + uses: appleboy/ssh-action@v1 + env: + NLB_ID: ${{ secrets.NLB_ID }} + ACCESS_TOKEN_SECRET_KEY: ${{ secrets.ACCESS_TOKEN_SECRET_KEY }} + REFRESH_TOKEN_SECRET_KEY: ${{ secrets.REFRESH_TOKEN_SECRET_KEY }} + DB_URL: ${{ secrets.DB_URL }} + DB_USERNAME: ${{ secrets.DB_USERNAME }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + with: + host: ${{ secrets.SSH_HOST_01 }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script_stop: true + envs: NLB_ID,ACCESS_TOKEN_SECRET_KEY,REFRESH_TOKEN_SECRET_KEY,DB_URL,DB_USERNAME,DB_PASSWORD + script: | + cd ~/snackgame-server/repo + git fetch origin main + git checkout main + git pull origin main + chmod +x scripts/deploy-rolling.sh + scripts/deploy-rolling.sh \ + ${{ secrets.NLB_BACKEND_01_HTTP }} \ + ${{ secrets.NLB_BACKEND_01_HTTPS }} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index b4bc6c93..17853408 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,6 +7,7 @@ plugins { id("io.spring.dependency-management") version "1.1.0" id("org.jetbrains.kotlin.kapt") version "1.9.24" id("io.freefair.lombok") version "8.10" + id("com.google.cloud.tools.jib") version "3.4.5" } java.sourceCompatibility = JavaVersion.VERSION_17 @@ -15,11 +16,6 @@ repositories { mavenCentral() } -allOpen { - annotation("jakarta.persistence.Entity") - annotation("jakarta.persistence.Embeddable") - annotation("jakarta.persistence.MappedSuperclass") -} dependencies { implementation("org.springframework.boot:spring-boot-starter-jdbc") @@ -72,6 +68,31 @@ tasks.compileTestKotlin { jvmTarget = "17" } } +jib { + from { + image = "amazoncorretto:17-alpine" + platforms { + platform { + os = "linux" + architecture = "arm64" + } + platform { + os = "linux" + architecture = "amd64" + } + } + } + to { + image = "ghcr.io/snack-game/server" + auth { + username = "snack-game" + password = System.getenv("GHCR_PASSWORD") ?: "" + } + } + container { + mainClass = "com.snackgame.server.ServerApplication" + } +} kapt { keepJavacAnnotationProcessors = true diff --git a/scripts/deploy-dev.sh b/scripts/deploy-dev.sh new file mode 100644 index 00000000..939c7b00 --- /dev/null +++ b/scripts/deploy-dev.sh @@ -0,0 +1,61 @@ + +set -euo pipefail + + +COMPOSE_DIR="$HOME/snackgame-server/snackgame-dev" +HEALTH_CHECK_URL="http://localhost:8081/rankings/1?by=BEST_SCORE" +HEALTH_CHECK_TIMEOUT=120 +HEALTH_CHECK_INTERVAL=5 + + +: "${ACCESS_TOKEN_SECRET_KEY:?ACCESS_TOKEN_SECRET_KEY 환경변수가 설정되지 않았습니다}" +: "${REFRESH_TOKEN_SECRET_KEY:?REFRESH_TOKEN_SECRET_KEY 환경변수가 설정되지 않았습니다}" +: "${DB_URL:?DB_URL 환경변수가 설정되지 않았습니다}" +: "${DB_USERNAME:?DB_USERNAME 환경변수가 설정되지 않았습니다}" +: "${DB_PASSWORD:?DB_PASSWORD 환경변수가 설정되지 않았습니다}" + + +wait_healthy() { + echo "[헬스체크] 시작..." + local elapsed=0 + local http_code="000" + + while [ "$elapsed" -lt "$HEALTH_CHECK_TIMEOUT" ]; do + http_code=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_CHECK_URL" 2>/dev/null || echo "000") + if [ "$http_code" = "200" ]; then + echo "[헬스체크] 통과 (${elapsed}초)" + return 0 + fi + echo "[헬스체크] 대기 중... (${elapsed}초, 응답: $http_code)" + sleep "$HEALTH_CHECK_INTERVAL" + elapsed=$((elapsed + HEALTH_CHECK_INTERVAL)) + done + + echo "[헬스체크] 실패 - ${HEALTH_CHECK_TIMEOUT}초 초과" + return 1 +} + + +echo "======================================" +echo "[Dev 배포 시작]" +echo "======================================" + +cd "$COMPOSE_DIR" + + +echo "[Pull] 새 이미지 받는 중..." +docker compose pull snackgame-dev-server + + +echo "[배포] 컨테이너 교체 중..." +docker compose up -d --no-deps snackgame-dev-server + + +wait_healthy + + +docker image prune -f + +echo "======================================" +echo "[Dev 배포 완료]" +echo "======================================" \ No newline at end of file diff --git a/scripts/deploy-rolling.sh b/scripts/deploy-rolling.sh new file mode 100755 index 00000000..9803f692 --- /dev/null +++ b/scripts/deploy-rolling.sh @@ -0,0 +1,121 @@ + +set -euo pipefail + + +if [ $# -ne 2 ]; then + echo "Usage: $0 " + exit 1 +fi + +HTTP_BACKEND="$1" +HTTPS_BACKEND="$2" + + +COMPOSE_DIR="$HOME/snackgame-server" +HEALTH_CHECK_URL="http://localhost:8080/rankings/1?by=BEST_SCORE" +HEALTH_CHECK_TIMEOUT=120 +HEALTH_CHECK_INTERVAL=5 + + +: "${NLB_ID:?NLB_ID 환경변수가 설정되지 않았습니다}" +: "${ACCESS_TOKEN_SECRET_KEY:?ACCESS_TOKEN_SECRET_KEY 환경변수가 설정되지 않았습니다}" +: "${REFRESH_TOKEN_SECRET_KEY:?REFRESH_TOKEN_SECRET_KEY 환경변수가 설정되지 않았습니다}" +: "${DB_URL:?DB_URL 환경변수가 설정되지 않았습니다}" +: "${DB_USERNAME:?DB_USERNAME 환경변수가 설정되지 않았습니다}" +: "${DB_PASSWORD:?DB_PASSWORD 환경변수가 설정되지 않았습니다}" + + +set_drain() { + local is_drain="$1" + echo "[NLB] 드레인 설정: http=$HTTP_BACKEND, https=$HTTPS_BACKEND -> is-drain=$is_drain" + + oci nlb backend update \ + --auth instance_principal \ + --network-load-balancer-id "$NLB_ID" \ + --backend-set-name snackgame-http \ + --backend-name "$HTTP_BACKEND" \ + --is-drain "$is_drain" \ + --is-backup false \ + --is-offline false \ + --wait-for-state SUCCEEDED \ + --force + + oci nlb backend update \ + --auth instance_principal \ + --network-load-balancer-id "$NLB_ID" \ + --backend-set-name snackgame-https \ + --backend-name "$HTTPS_BACKEND" \ + --is-drain "$is_drain" \ + --is-backup false \ + --is-offline false \ + --wait-for-state SUCCEEDED \ + --force +} + + +wait_healthy() { + echo "[헬스체크] 시작..." + local elapsed=0 + local http_code="000" + + while [ "$elapsed" -lt "$HEALTH_CHECK_TIMEOUT" ]; do + + if [ "$(docker inspect -f '{{.State.Running}}' snackgame-server 2>/dev/null)" != "true" ]; then + echo "[헬스체크] 컨테이너가 종료됨, 즉시 롤백" + return 1 + fi + http_code=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_CHECK_URL" 2>/dev/null || echo "000") + if [ "$http_code" = "200" ]; then + echo "[헬스체크] 통과 (${elapsed}초)" + return 0 + fi + echo "[헬스체크] 대기 중... (${elapsed}초, 응답: $http_code)" + sleep "$HEALTH_CHECK_INTERVAL" + elapsed=$((elapsed + HEALTH_CHECK_INTERVAL)) + done + + echo "[헬스체크] 실패 - ${HEALTH_CHECK_TIMEOUT}초 초과" + return 1 +} + + +rollback() { + echo "[롤백] 이전 컨테이너로 복구 중..." + cd "$COMPOSE_DIR" + docker compose up -d + set_drain false + echo "[롤백] 완료" + exit 1 +} + + +echo "======================================" +echo "[배포 시작]" +echo "[대상] HTTP=$HTTP_BACKEND, HTTPS=$HTTPS_BACKEND" +echo "======================================" + +cd "$COMPOSE_DIR" + + +set_drain true + + +echo "[Pull] 새 이미지 받는 중..." +docker compose pull + + +echo "[배포] 컨테이너 교체 중..." +docker compose up -d || rollback + + +wait_healthy || rollback + + +set_drain false + + +docker image prune -f + +echo "======================================" +echo "[배포 완료]" +echo "======================================" \ No newline at end of file diff --git a/src/main/java/com/snackgame/server/common/exception/GlobalExceptionHandler.kt b/src/main/java/com/snackgame/server/common/exception/GlobalExceptionHandler.kt index d8519246..dacdd4f4 100644 --- a/src/main/java/com/snackgame/server/common/exception/GlobalExceptionHandler.kt +++ b/src/main/java/com/snackgame/server/common/exception/GlobalExceptionHandler.kt @@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException +import org.springframework.web.multipart.MultipartException import org.springframework.web.servlet.NoHandlerFoundException import java.util.stream.Collectors @@ -95,6 +96,14 @@ class GlobalExceptionHandler( return ExceptionResponse("요청 인자가 잘못되었습니다", exception.javaClass) } + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + fun handleMultipartException(exception: MultipartException): ExceptionResponse { + log.warn(exception.message) + + return ExceptionResponse("잘못된 멀티파트 요청입니다", exception.javaClass) + } + @ExceptionHandler @ResponseStatus(HttpStatus.BAD_REQUEST) fun handleMethodArgumentNotValid(exception: MethodArgumentNotValidException): ExceptionResponse { diff --git a/src/main/java/com/snackgame/server/game/session/domain/Session.kt b/src/main/java/com/snackgame/server/game/session/domain/Session.kt index 55f98c6e..18512293 100644 --- a/src/main/java/com/snackgame/server/game/session/domain/Session.kt +++ b/src/main/java/com/snackgame/server/game/session/domain/Session.kt @@ -13,16 +13,16 @@ import javax.persistence.MappedSuperclass @MappedSuperclass abstract class Session( - val ownerId: Long, + open val ownerId: Long, timeLimit: Duration = SessionState.TTL, score: Int = 0, @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - val sessionId: Long = 0 + open val sessionId: Long = 0 ) : BaseEntity() { @Embedded private val sessionState = SessionState(timeLimit) - var score: Int = score + open var score: Int = score protected set(value) { sessionState.validateInProgress() if (value < field) { diff --git a/src/main/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBiz.kt b/src/main/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBiz.kt index 8a51149b..4258d403 100644 --- a/src/main/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBiz.kt +++ b/src/main/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBiz.kt @@ -30,8 +30,8 @@ open class SnackgameBiz( @Lob @Convert(converter = BoardConverter::class) - var board = board - private set + open var board = board + protected set @Deprecated("스트릭 구현 시 제거 예정") fun setScoreUnsafely(score: Int) { @@ -39,8 +39,8 @@ open class SnackgameBiz( } @Embedded - var feverTime: FeverTime? = null - private set + open var feverTime: FeverTime? = null + protected set fun remove(streak: Streak) { diff --git a/src/main/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBizV2.kt b/src/main/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBizV2.kt index 775ad63f..c1de0224 100644 --- a/src/main/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBizV2.kt +++ b/src/main/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBizV2.kt @@ -30,12 +30,12 @@ open class SnackgameBizV2( @Lob @Convert(converter = BoardConverter::class) - var board = board - private set + open var board = board + protected set @Embedded - var feverTime: FeverTime? = null - private set + open var feverTime: FeverTime? = null + protected set fun remove(streak: Streak) { val removedSnacks = board.removeSnacksIn(streak) diff --git a/src/main/java/com/snackgame/server/game/snackgame/core/controller/SnackgameController.kt b/src/main/java/com/snackgame/server/game/snackgame/core/controller/SnackgameController.kt index 75223d80..04c70660 100644 --- a/src/main/java/com/snackgame/server/game/snackgame/core/controller/SnackgameController.kt +++ b/src/main/java/com/snackgame/server/game/snackgame/core/controller/SnackgameController.kt @@ -98,8 +98,10 @@ SnackgameController( @Operation(summary = "스낵게임 세션 종료", description = "세션을 종료한다") @PostMapping("/{sessionId}/end") - fun end(@Authenticated member: Member, @PathVariable sessionId: Long): SnackgameEndResponse = + fun end(@Authenticated member: Member, @PathVariable sessionId: Long): SnackgameEndResponse { snackgameService.end(member.id, sessionId) + return snackgameService.getEndResponse(sessionId) + } @Operation(summary = "사용자가 가진 아이템 조회", description = "사용자가 아이템을 각각 몇 개 소유하고 있는지 조회한다") @GetMapping("/items") diff --git a/src/main/java/com/snackgame/server/game/snackgame/core/domain/Snackgame.kt b/src/main/java/com/snackgame/server/game/snackgame/core/domain/Snackgame.kt index de510b9c..658b4960 100644 --- a/src/main/java/com/snackgame/server/game/snackgame/core/domain/Snackgame.kt +++ b/src/main/java/com/snackgame/server/game/snackgame/core/domain/Snackgame.kt @@ -21,12 +21,12 @@ open class Snackgame( @Lob @Convert(converter = BoardConverter::class) - var board = board - private set + open var board = board + protected set @Embedded - var feverTime: FeverTime? = null - private set + open var feverTime: FeverTime? = null + protected set @Deprecated("스트릭 구현 완료 시 제거") fun setScoreUnsafely(score: Int) { diff --git a/src/main/java/com/snackgame/server/game/snackgame/core/domain/SnackgameRepository.kt b/src/main/java/com/snackgame/server/game/snackgame/core/domain/SnackgameRepository.kt index ddf978fc..3d28579b 100644 --- a/src/main/java/com/snackgame/server/game/snackgame/core/domain/SnackgameRepository.kt +++ b/src/main/java/com/snackgame/server/game/snackgame/core/domain/SnackgameRepository.kt @@ -13,11 +13,19 @@ interface SnackgameRepository : JpaRepository { @Query( value = """ - with scores as ( - select percent_rank() over (order by score desc) as percentile, session_id, score - from snackgame where TIMESTAMPDIFF(SECOND, now(), expires_at) <=0 - ) - select percentile from scores where session_id = :sessionId""", + SELECT percent_rank() over (order by score desc) as percentile + FROM snackgame + WHERE expires_at <= now() + INTERVAL 1 SECOND + AND session_id = :sessionId + + UNION ALL + + SELECT 0.0 as percentile + FROM snackgame + WHERE session_id = :sessionId + AND expires_at > now() + INTERVAL 1 SECOND + LIMIT 1 + """, nativeQuery = true ) fun findPercentileOf(sessionId: Long): Double? diff --git a/src/main/java/com/snackgame/server/game/snackgame/core/service/SnackgameService.kt b/src/main/java/com/snackgame/server/game/snackgame/core/service/SnackgameService.kt index e494aa91..4d377073 100644 --- a/src/main/java/com/snackgame/server/game/snackgame/core/service/SnackgameService.kt +++ b/src/main/java/com/snackgame/server/game/snackgame/core/service/SnackgameService.kt @@ -4,6 +4,7 @@ package com.snackgame.server.game.snackgame.core.service import com.snackgame.server.game.session.event.SessionEndEvent import com.snackgame.server.game.session.event.SessionPauseEvent import com.snackgame.server.game.session.event.SessionResumeEvent +import com.snackgame.server.game.session.exception.NoSuchSessionException import com.snackgame.server.game.snackgame.core.domain.Snackgame import com.snackgame.server.game.snackgame.core.domain.SnackgameRepository import com.snackgame.server.game.snackgame.core.domain.Streak @@ -19,6 +20,7 @@ import com.snackgame.server.game.snackgame.core.service.dto.StreaksRequest import org.slf4j.LoggerFactory import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation import org.springframework.transaction.annotation.Transactional @Service @@ -97,25 +99,39 @@ class SnackgameService( return SnackgameResponse.of(game) } + @Transactional - fun end(memberId: Long, sessionId: Long): SnackgameEndResponse { + fun end(memberId: Long, sessionId: Long) { log.info("[게임 종료 시도] memberId: $memberId, sessionId: $sessionId") - - // DB에서 세션 ID로만 조회하여 실제 ownerId 확인 - val sessionById = snackGameRepository.findById(sessionId) - if (sessionById.isPresent) { - val actualOwnerId = sessionById.get().ownerId - log.info("[세션 존재 확인] sessionId: $sessionId, actualOwnerId: $actualOwnerId, requestMemberId: $memberId, 일치여부: ${actualOwnerId == memberId}") - } else { - log.warn("[세션 없음] sessionId: $sessionId 가 DB에 존재하지 않음") - } - val game = snackGameRepository.getBy(memberId, sessionId) - log.info("[세션 조회 성공] sessionId: $sessionId, ownerId: ${game.ownerId}, score: ${game.score}") + log.info("[세션 조회 성공] sessionId: $sessionId") game.end() eventPublisher.publishEvent(SessionEndEvent.of(game)) + log.info("[게임 종료 완료] sessionId: $sessionId") + } + + + @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW) + fun getEndResponse(sessionId: Long): SnackgameEndResponse { + log.info("[결과 조회 시작] sessionId: $sessionId") + + val game = snackGameRepository.findById(sessionId) + .orElseThrow { + log.error("[세션 없음] sessionId: $sessionId") + NoSuchSessionException() + } + + log.info("[세션 조회 완료]") + + val percentile = try { + snackGameRepository.ratePercentileOf(sessionId) + } catch (e: Exception) { + log.error("[Percentile 조회 실패] sessionId: $sessionId", e) + throw e + } - return SnackgameEndResponse.of(game, snackGameRepository.ratePercentileOf(sessionId)) + log.info("[결과 조회 완료] percentile: ${percentile.percentage()}") + return SnackgameEndResponse.of(game, percentile) } } diff --git a/src/main/java/com/snackgame/server/member/domain/Member.kt b/src/main/java/com/snackgame/server/member/domain/Member.kt index 12e6d5a1..20b07232 100644 --- a/src/main/java/com/snackgame/server/member/domain/Member.kt +++ b/src/main/java/com/snackgame/server/member/domain/Member.kt @@ -24,23 +24,23 @@ open class Member( group: Group? = null, profileImage: ProfileImage = ProfileImage.EMPTY, @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - var id: Long = 0 + open var id: Long = 0 ) : BaseEntity() { @Embedded - var name: Name = name - private set + open var name: Name = name + protected set @ManyToOne - var group: Group? = group - private set + open var group: Group? = group + protected set @Embedded - var profileImage: ProfileImage = profileImage - private set + open var profileImage: ProfileImage = profileImage + protected set @Embedded - val status: Status = Status() + open val status: Status = Status() private var isValid = true