From 9ff663cc18cf0f325bf32edb91087a59f7a047a8 Mon Sep 17 00:00:00 2001 From: swthewhite Date: Wed, 14 Jan 2026 10:01:53 +0900 Subject: [PATCH 1/5] =?UTF-8?q?UPLUS-73=20chore:=20CI/CD=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EB=B0=8F=20Docker=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/CODEOWNERS | 53 ++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 16 ++- .github/workflows/deploy.yml | 135 ++++++++++++++++++++++-- .github/workflows/integrate.yml | 120 +++++++++++++++++++-- .github/workflows/pr-approval-check.yml | 64 +++++++++++ .github/workflows/pr-labeler.yml | 101 ++++++++++++++++++ .github/workflows/pr-reviewer.yml | 53 ++++++++++ .github/workflows/stale-pr.yml | 44 ++++++++ 8 files changed, 567 insertions(+), 19 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/pr-approval-check.yml create mode 100644 .github/workflows/pr-labeler.yml create mode 100644 .github/workflows/pr-reviewer.yml create mode 100644 .github/workflows/stale-pr.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..b24dcf3 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,53 @@ +# ============================================================================= +# CODEOWNERS - 코드 소유자 설정 +# ============================================================================= +# 이 파일은 주석처리된 상태로 생성되었습니다. +# v1 완료 후 필요한 줄의 주석을 해제하여 사용하세요. +# +# 문법: +# <파일/폴더 패턴> @사용자1 @사용자2 ... +# +# 참고: +# - 파일 패턴은 .gitignore와 동일한 형식을 사용합니다. +# - 마지막에 매칭된 규칙이 적용됩니다 (순서 중요). +# - CODEOWNERS에 지정된 사용자는 자동으로 리뷰어로 요청됩니다. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# 전체 코드 기본 리뷰어 +# ----------------------------------------------------------------------------- +# * @team-lead @tech-lead + +# ----------------------------------------------------------------------------- +# GitHub Actions 및 CI/CD 설정 +# ----------------------------------------------------------------------------- +# .github/ @devops-team +# Dockerfile @devops-team +# docker-compose*.yml @devops-team + +# ----------------------------------------------------------------------------- +# 빌드 설정 +# ----------------------------------------------------------------------------- +# build.gradle* @backend-lead +# settings.gradle* @backend-lead +# gradle/ @backend-lead + +# ----------------------------------------------------------------------------- +# 애플리케이션 설정 +# ----------------------------------------------------------------------------- +# src/main/resources/application*.yml @backend-lead +# src/main/resources/application*.properties @backend-lead + +# ----------------------------------------------------------------------------- +# 도메인별 담당자 (예시) +# ----------------------------------------------------------------------------- +# src/main/java/**/domain/user/ @user-domain-owner +# src/main/java/**/domain/order/ @order-domain-owner +# src/main/java/**/domain/payment/ @payment-domain-owner + +# ----------------------------------------------------------------------------- +# 인프라/공통 모듈 +# ----------------------------------------------------------------------------- +# src/main/java/**/common/ @backend-lead +# src/main/java/**/config/ @backend-lead +# src/main/java/**/security/ @security-lead diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 811ec35..9689679 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,7 @@ -## 🎫 지라 티켓 - +## 🍀 이슈 번호 + +- #이슈번호 --- @@ -9,6 +10,17 @@ +--- + +## 📋 체크리스트 + + + +- [ ] 코드가 정상적으로 빌드됩니다. +- [ ] 관련 테스트 코드를 작성했습니다. +- [ ] 기존 테스트가 모두 통과합니다. +- [ ] 코드 스타일(Spotless, Checkstyle)을 준수합니다. + --- ## ⌨ 기타 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8fdae00..106d5ab 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,7 +1,18 @@ # ============================================================================ # Backend Deploy Workflow # ============================================================================ -# 이 파일을 backend 레포지토리의 .github/workflows/deploy.yml 로 저장하세요. +# 역할: +# - Integrate workflow 성공 후 자동 실행 +# - integrate에서 생성된 JAR artifact 사용 (빌드 재수행 없음) +# - Docker 이미지 빌드 및 ECR 푸시 +# - ECS 서비스 배포 +# +# Job 구조: +# prepare ──> build-image ──> deploy +# +# 트리거: +# - Integrate Backend workflow가 main 브랜치에서 성공적으로 완료된 후 +# - 수동 실행 (workflow_dispatch) - 최근 성공한 integrate run의 artifact 사용 # # GitHub Secrets (Settings > Secrets and variables > Actions > Secrets): # - AWS_ACCESS_KEY_ID: AWS IAM Access Key @@ -16,7 +27,10 @@ name: Deploy Backend to ECS on: - push: + workflow_run: + workflows: ["Integrate Backend"] + types: + - completed branches: - main workflow_dispatch: @@ -25,14 +39,104 @@ env: AWS_REGION: ap-northeast-2 jobs: - deploy: - name: Build and Deploy + # ========================================================================== + # Prepare Job - Artifact 준비 + # ========================================================================== + prepare: + name: Prepare runs-on: ubuntu-latest + # workflow_run 트리거일 경우 integrate 성공 및 push 이벤트일 때만 실행 + if: > + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push') + + outputs: + run_id: ${{ steps.get-run-info.outputs.run_id }} + head_sha: ${{ steps.get-run-info.outputs.head_sha }} steps: - name: Checkout code uses: actions/checkout@v4 + - name: Get workflow run info + id: get-run-info + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + echo "Manual trigger detected. Finding latest successful integrate run..." + + # 최근 성공한 Integrate Backend workflow run 조회 + RUN_INFO=$(gh run list \ + --workflow "Integrate Backend" \ + --branch main \ + --status success \ + --event push \ + --limit 1 \ + --json databaseId,headSha) + + RUN_ID=$(echo "$RUN_INFO" | jq -r '.[0].databaseId') + HEAD_SHA=$(echo "$RUN_INFO" | jq -r '.[0].headSha') + + if [ "$RUN_ID" == "null" ] || [ -z "$RUN_ID" ]; then + echo "::error::No successful integrate workflow run found" + exit 1 + fi + + echo "Found run ID: $RUN_ID, commit: $HEAD_SHA" + else + echo "workflow_run trigger detected" + RUN_ID="${{ github.event.workflow_run.id }}" + HEAD_SHA="${{ github.event.workflow_run.head_sha }}" + fi + + echo "run_id=$RUN_ID" >> $GITHUB_OUTPUT + echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT + + - name: Download artifact from Integrate workflow + uses: actions/download-artifact@v4 + with: + name: spring-boot-app + path: build/libs + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ steps.get-run-info.outputs.run_id }} + + - name: Verify and upload artifact + run: | + echo "Downloaded artifacts:" + ls -la build/libs/ + JAR_FILE=$(ls build/libs/*.jar | head -1) + echo "JAR file: $JAR_FILE" + + - name: Upload artifact for next jobs + uses: actions/upload-artifact@v4 + with: + name: deploy-artifact + path: build/libs/*.jar + retention-days: 1 + + # ========================================================================== + # Build Image Job - Docker 빌드 및 ECR 푸시 + # ========================================================================== + build-image: + name: Build Image + runs-on: ubuntu-latest + needs: [prepare] + + outputs: + image_tag: ${{ needs.prepare.outputs.head_sha }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: deploy-artifact + path: build/libs + - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: @@ -51,13 +155,30 @@ jobs: uses: docker/build-push-action@v5 with: context: . + file: Dockerfile.deploy push: true tags: | - ${{ steps.login-ecr.outputs.registry }}/${{ vars.ECR_REPOSITORY }}:${{ github.sha }} + ${{ steps.login-ecr.outputs.registry }}/${{ vars.ECR_REPOSITORY }}:${{ needs.prepare.outputs.head_sha }} ${{ steps.login-ecr.outputs.registry }}/${{ vars.ECR_REPOSITORY }}:latest cache-from: type=gha cache-to: type=gha,mode=max + # ========================================================================== + # Deploy Job - ECS 배포 + # ========================================================================== + deploy: + name: Deploy + runs-on: ubuntu-latest + needs: [prepare, build-image] + + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + - name: Deploy to ECS run: | aws ecs update-service \ @@ -76,7 +197,9 @@ jobs: - name: Deployment Summary run: | echo "## Deployment Summary" >> $GITHUB_STEP_SUMMARY - echo "- **Image Tag**: ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY + echo "- **Image Tag**: ${{ needs.prepare.outputs.head_sha }}" >> $GITHUB_STEP_SUMMARY + echo "- **Source Run ID**: ${{ needs.prepare.outputs.run_id }}" >> $GITHUB_STEP_SUMMARY echo "- **ECS Cluster**: ${{ vars.ECS_CLUSTER }}" >> $GITHUB_STEP_SUMMARY echo "- **ECS Service**: ${{ vars.ECS_SERVICE }}" >> $GITHUB_STEP_SUMMARY echo "- **Region**: ${{ env.AWS_REGION }}" >> $GITHUB_STEP_SUMMARY + echo "- **Triggered by**: ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/integrate.yml b/.github/workflows/integrate.yml index fd98273..7cb764c 100644 --- a/.github/workflows/integrate.yml +++ b/.github/workflows/integrate.yml @@ -5,6 +5,11 @@ # - 소스 코드 정적 분석 (Spotless, Checkstyle) # - 테스트 수행 및 JaCoCo 커버리지 생성 # - SonarCloud 코드 품질/커버리지/보안 분석 +# - JAR artifact 생성 (deploy workflow에서 사용) +# +# Job 구조: +# lint ──┬──> analyze ──> build +# test ──┘ # # GitHub Secrets: # - SONAR_TOKEN : SonarCloud Token @@ -27,21 +32,16 @@ env: JAVA_VERSION: '17' jobs: - integrate: - name: Backend Integration Pipeline + # ========================================================================== + # Lint Job - 코드 스타일 검사 + # ========================================================================== + lint: + name: Lint runs-on: ubuntu-latest - permissions: - contents: read - actions: write - checks: write - pull-requests: write - steps: - name: Checkout source uses: actions/checkout@v4 - with: - fetch-depth: 0 # SonarCloud 분석을 위해 전체 히스토리 필요 - name: Set up JDK uses: actions/setup-java@v4 @@ -59,16 +59,114 @@ jobs: - name: Run Checkstyle run: ./gradlew checkstyleMain checkstyleTest + # ========================================================================== + # Test Job - 테스트 및 커버리지 + # ========================================================================== + test: + name: Test + runs-on: ubuntu-latest + + permissions: + contents: read + checks: write + pull-requests: write + + steps: + - name: Checkout source + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: temurin + cache: gradle + + - name: Grant execution to Gradle wrapper + run: chmod +x gradlew + - name: Run Tests with JaCoCo run: ./gradlew test jacocoTestReport + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: | + build/test-results/ + build/reports/jacoco/ + + # ========================================================================== + # Analyze Job - SonarCloud 분석 + # ========================================================================== + analyze: + name: Analyze + runs-on: ubuntu-latest + needs: [lint, test] + + steps: + - name: Checkout source + uses: actions/checkout@v4 + with: + fetch-depth: 0 # SonarCloud 분석을 위해 전체 히스토리 필요 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: temurin + cache: gradle + + - name: Cache SonarQube packages + uses: actions/cache@v4 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + + - name: Download test results + uses: actions/download-artifact@v4 + with: + name: test-results + path: build/ + + - name: Grant execution to Gradle wrapper + run: chmod +x gradlew + - name: SonarCloud Scan env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_ORGANIZATION: ${{ secrets.SONAR_ORGANIZATION }} SONAR_PROJECT: ${{ secrets.SONAR_PROJECT }} SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} - run: ./gradlew sonar -Dsonar.host.url=$SONAR_HOST_URL + run: ./gradlew sonar -Dsonar.host.url=$SONAR_HOST_URL --info + + # ========================================================================== + # Build Job - JAR 빌드 및 Artifact 업로드 + # ========================================================================== + build: + name: Build + runs-on: ubuntu-latest + needs: [analyze] + + permissions: + contents: read + actions: write + + steps: + - name: Checkout source + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: temurin + cache: gradle + + - name: Grant execution to Gradle wrapper + run: chmod +x gradlew - name: Build Spring Boot JAR run: ./gradlew bootJar diff --git a/.github/workflows/pr-approval-check.yml b/.github/workflows/pr-approval-check.yml new file mode 100644 index 0000000..0f20410 --- /dev/null +++ b/.github/workflows/pr-approval-check.yml @@ -0,0 +1,64 @@ +name: Approval Check + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: [main, develop] + pull_request_review: + types: [submitted, dismissed] + +jobs: + check-approval: + runs-on: ubuntu-latest + name: Approval Check + + steps: + - name: Check PR Approval Status + uses: actions/github-script@v7 + with: + script: | + const prNumber = context.payload.pull_request?.number || context.payload.review?.pull_request?.number; + const prAuthor = context.payload.pull_request?.user?.login || context.payload.review?.pull_request?.user?.login; + + if (!prNumber) { + core.setFailed('PR 번호를 찾을 수 없습니다.'); + return; + } + + console.log(`📋 PR #${prNumber} 승인 상태 확인 중...`); + console.log(` 작성자: ${prAuthor}`); + + // PR의 모든 리뷰 가져오기 + const { data: reviews } = await github.rest.pulls.listReviews({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + // 각 리뷰어의 최신 리뷰 상태만 확인 + const latestReviews = {}; + for (const review of reviews) { + const reviewer = review.user.login; + // PR 작성자의 리뷰는 무시 + if (reviewer.toLowerCase() === prAuthor.toLowerCase()) { + continue; + } + // 더 최신 리뷰로 덮어쓰기 + if (!latestReviews[reviewer] || new Date(review.submitted_at) > new Date(latestReviews[reviewer].submitted_at)) { + latestReviews[reviewer] = review; + } + } + + // APPROVED 상태인 리뷰 수 계산 + const approvals = Object.values(latestReviews).filter(r => r.state === 'APPROVED'); + + console.log(` 승인 수: ${approvals.length}`); + if (approvals.length > 0) { + console.log(` 승인자: ${approvals.map(r => r.user.login).join(', ')}`); + } + + if (approvals.length >= 1) { + console.log('✅ 필요한 승인을 받았습니다.'); + } else { + core.setFailed('❌ 최소 1명의 승인이 필요합니다. (PR 작성자 제외)'); + } diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 0000000..747b4c6 --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,101 @@ +name: PR Size Labeler + +on: + pull_request: + types: [opened, synchronize] + +jobs: + size-label: + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - name: Label PR by Size + uses: actions/github-script@v7 + with: + script: | + const prNumber = context.payload.pull_request.number; + + // PR의 변경 사항 가져오기 + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + const additions = pr.additions; + const deletions = pr.deletions; + const totalChanges = additions + deletions; + + console.log(`📊 PR #${prNumber} 변경 통계`); + console.log(` 추가: +${additions}`); + console.log(` 삭제: -${deletions}`); + console.log(` 총 변경: ${totalChanges} lines`); + + // 크기별 라벨 정의 + const sizeLabels = { + 'size/XS': { max: 10, color: '3CBF00' }, + 'size/S': { max: 50, color: '5D9801' }, + 'size/M': { max: 200, color: 'FBCA04' }, + 'size/L': { max: 500, color: 'FFA500' }, + 'size/XL': { max: Infinity, color: 'D93F0B' } + }; + + // 적절한 라벨 선택 + let selectedLabel = 'size/XL'; + for (const [label, config] of Object.entries(sizeLabels)) { + if (totalChanges <= config.max) { + selectedLabel = label; + break; + } + } + + console.log(`🏷️ 선택된 라벨: ${selectedLabel}`); + + // 기존 size 라벨 제거 + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + + for (const label of currentLabels) { + if (label.name.startsWith('size/')) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: label.name + }); + } + } + + // 라벨이 존재하는지 확인하고 없으면 생성 + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: selectedLabel + }); + } catch (error) { + if (error.status === 404) { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: selectedLabel, + color: sizeLabels[selectedLabel].color, + description: `PR size: ${selectedLabel.replace('size/', '')}` + }); + } + } + + // 새 라벨 추가 + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: [selectedLabel] + }); + + console.log(`✅ 라벨 적용 완료: ${selectedLabel}`); diff --git a/.github/workflows/pr-reviewer.yml b/.github/workflows/pr-reviewer.yml new file mode 100644 index 0000000..4bf6ae6 --- /dev/null +++ b/.github/workflows/pr-reviewer.yml @@ -0,0 +1,53 @@ +name: Auto Assign Reviewer + +on: + pull_request: + types: [opened, ready_for_review] + branches: [main, develop] + +jobs: + assign-reviewer: + runs-on: ubuntu-latest + # Draft PR은 건너뛰기 + if: github.event.pull_request.draft == false + + steps: + - name: Auto Assign Reviewer + uses: actions/github-script@v7 + with: + script: | + // ============================================================ + // 리뷰어 목록 설정 + // scripts/github-config.env의 REVIEWERS 값과 동기화하세요 + // ============================================================ + const reviewers = ['arkchive', 'arlen02-01', 'k0081915', 'paul0755', 'starboxxxx', 'swthewhite']; + + const prAuthor = context.payload.pull_request.user.login; + const prNumber = context.payload.pull_request.number; + + // PR 작성자를 제외한 리뷰어 후보 + const candidates = reviewers.filter(r => r.toLowerCase() !== prAuthor.toLowerCase()); + + if (candidates.length === 0) { + console.log('⚠️ 할당 가능한 리뷰어가 없습니다.'); + return; + } + + // 랜덤으로 1명 선택 + const selectedReviewer = candidates[Math.floor(Math.random() * candidates.length)]; + + console.log(`📌 PR #${prNumber} - 리뷰어 할당: ${selectedReviewer}`); + + try { + await github.rest.pulls.requestReviewers({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + reviewers: [selectedReviewer] + }); + + console.log(`✅ 리뷰어 할당 완료: ${selectedReviewer}`); + } catch (error) { + console.log(`❌ 리뷰어 할당 실패: ${error.message}`); + // 실패해도 워크플로우는 성공으로 처리 (리뷰어가 collaborator가 아닐 수 있음) + } diff --git a/.github/workflows/stale-pr.yml b/.github/workflows/stale-pr.yml new file mode 100644 index 0000000..bca1c55 --- /dev/null +++ b/.github/workflows/stale-pr.yml @@ -0,0 +1,44 @@ +name: Stale PR Check + +on: + schedule: + # 매일 오전 9시 (KST) 실행 = UTC 0시 + - cron: '0 0 * * *' + workflow_dispatch: + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - name: Mark Stale PRs + uses: actions/stale@v9 + with: + # PR 설정 + stale-pr-message: | + ⚠️ 이 PR은 **1일** 동안 활동이 없어 stale로 표시되었습니다. + + 리뷰가 필요하거나 작업 중인 경우 코멘트를 남겨주세요. + 더 이상 필요하지 않은 경우 PR을 닫아주세요. + + stale-pr-label: 'stale' + days-before-pr-stale: 1 + days-before-pr-close: -1 # 자동 close 비활성화 + + # Issue는 처리하지 않음 + days-before-issue-stale: -1 + days-before-issue-close: -1 + + # stale 라벨 해제 조건 + remove-stale-when-updated: true + + # 제외할 라벨 + exempt-pr-labels: 'wip,blocked,do-not-close' + + # Draft PR 제외 + exempt-draft-pr: true + + # 처리할 PR 수 + operations-per-run: 30 From 4364a6b49f25f45a42886d05731531ef35082d5c Mon Sep 17 00:00:00 2001 From: swthewhite Date: Wed, 14 Jan 2026 10:02:33 +0900 Subject: [PATCH 2/5] =?UTF-8?q?UPLUS-73=20chore:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=ED=92=88=EC=A7=88=20=EC=84=A4=EC=A0=95=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- config/checkstyle/checkstyle.xml | 487 +++++++++++++++++++++++++++---- 2 files changed, 430 insertions(+), 59 deletions(-) diff --git a/build.gradle b/build.gradle index 61340b8..8615769 100644 --- a/build.gradle +++ b/build.gradle @@ -105,7 +105,7 @@ jacocoTestCoverageVerification { // Checkstyle (Code Style) // ============================================================================ checkstyle { - toolVersion = '10.18.2' + toolVersion = '10.21.4' configFile = file("${rootDir}/config/checkstyle/checkstyle.xml") ignoreFailures = false maxWarnings = 0 diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml index e1d2f63..9568b84 100644 --- a/config/checkstyle/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -1,73 +1,444 @@ + "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN" + "https://checkstyle.org/dtds/configuration_1_3.dtd"> + Checkstyle configuration that checks the Google coding conventions from Google Java Style + that can be found at https://google.github.io/styleguide/javaguide.html + + Checkstyle is very configurable. Be sure to read the documentation at + http://checkstyle.org (or in your downloaded distribution). + + To completely disable a check, just comment it out or delete it from the file. + To suppress certain violations please review suppression filters. + + Authors: Max Vetrenko, Mauryan Kansara, Ruslan Diachenko, Roman Ivanov. + --> + - - - - - - - + - - - - - + - - - - - - - - - - - - - - + + + + + + - - - - + - - - - - - - + + + + + - - - - - - - - - + + + + + + - - - - - + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From a76d20e268796c6d074cbe3707562600397a823e Mon Sep 17 00:00:00 2001 From: swthewhite Date: Wed, 14 Jan 2026 10:03:11 +0900 Subject: [PATCH 3/5] =?UTF-8?q?UPLUS-73=20chore:=20=EB=AA=A8=EB=8B=88?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(logback,=20actuator,=20prometheus)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 8 +++++- src/main/resources/application.yml | 41 +++++++++++++++++++++++++-- src/main/resources/logback-spring.xml | 39 +++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 src/main/resources/logback-spring.xml diff --git a/build.gradle b/build.gradle index 8615769..65586b7 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,12 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-actuator' + // Monitoring & Observability + implementation 'io.micrometer:micrometer-registry-prometheus' + implementation 'net.logstash.logback:logstash-logback-encoder:7.4' + implementation 'io.micrometer:micrometer-tracing-bridge-otel' + implementation 'io.opentelemetry:opentelemetry-exporter-otlp' + // Database implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'org.postgresql:postgresql' @@ -85,7 +91,7 @@ jacocoTestCoverageVerification { violationRules { rule { limit { - minimum = 0.75 // 75% 미만이면 빌드 실패 + minimum = 0.8 } } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 474538a..b2d240c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,11 @@ +project: + name: API Core + version: 1.0.0 + spring: + application: + name: api-core + datasource: driver-class-name: org.postgresql.Driver url: jdbc:postgresql://localhost:5433/${POSTGRES_DB:app_db} @@ -20,7 +27,37 @@ spring: format_sql: true open-in-view: false +server: + port: 8080 + +management: + endpoints: + web: + exposure: + include: health,info,prometheus,metrics,loggers + endpoint: + health: + show-details: always + probes: + enabled: true + prometheus: + enabled: true + prometheus: + metrics: + export: + enabled: true + metrics: + tags: + application: ${spring.application.name} + tracing: + sampling: + probability: ${OTEL_SAMPLING_PROBABILITY:1.0} + otlp: + tracing: + endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318/v1/traces} + logging: level: - org.hibernate.SQL: debug - org.hibernate.orm.jdbc.bind: trace + root: INFO + org.springframework.web: INFO + org.hibernate.SQL: WARN diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..bbe9bf8 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,39 @@ + + + + + + + + + + ts + level + logger + msg + + {"service":"${APP_NAME}","env":"${APP_ENV}"} + traceId + spanId + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + From 067ed2243ba0a419dae87114fc8a5f23ec27ed06 Mon Sep 17 00:00:00 2001 From: swthewhite Date: Wed, 14 Jan 2026 10:04:51 +0900 Subject: [PATCH 4/5] =?UTF-8?q?UPLUS-73=20style:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=ED=8F=AC=EB=A7=B7=ED=8C=85=20=EB=B0=8F=20checkstyle=20?= =?UTF-8?q?=EC=9C=84=EB=B0=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/controller/PlanController.java | 12 +-- .../core/controller/UserController.java | 15 ++-- .../controller/dto/PlanChangeRequest.java | 5 +- .../dto/Request/ChangeEmailRequest.java | 7 +- .../dto/Request/ChangeGradeRequest.java | 6 +- .../dto/Request/FindUserRequest.java | 8 +- .../dto/Request/SaveExampleRequest.java | 8 +- .../dto/response/ChangeEmailResponse.java | 4 +- .../dto/response/ChangeGradeResponse.java | 4 +- .../core/infra/entity/customer/Customer.java | 78 +++++++++-------- .../infra/entity/customer/enums/Grade.java | 6 +- .../project/core/infra/entity/plan/Plan.java | 31 ++++--- .../infra/entity/plan/SubscriptionPlan.java | 83 ++++++++++--------- .../entity/plan/enums/AllowancePeriod.java | 4 +- .../entity/subscription/Subscription.java | 74 ++++++++++------- .../enums/SubscriptionStatus.java | 6 +- .../infra/entity/vas/SubscriptionVas.java | 26 +++--- .../infra/repository/plan/PlanRepository.java | 3 +- .../plan/SubscriptionPlanRepository.java | 11 +-- .../subscription/SubscriptionRepository.java | 3 +- .../infra/repository/user/UserRepository.java | 7 +- .../com/project/core/service/PlanService.java | 57 +++++++------ .../com/project/core/service/UserService.java | 61 ++++++++------ .../project/global/config/RedisConfig.java | 30 +++---- .../exception/ApplicationException.java | 6 +- .../global/exception/BaseException.java | 22 ++--- .../global/exception/ExceptionAdvice.java | 48 +++++------ .../exception/code/domain/BaseErrorCode.java | 11 ++- .../code/domain/GlobalErrorCode.java | 18 ++-- 29 files changed, 327 insertions(+), 327 deletions(-) diff --git a/src/main/java/com/project/core/controller/PlanController.java b/src/main/java/com/project/core/controller/PlanController.java index bb43e37..815e076 100644 --- a/src/main/java/com/project/core/controller/PlanController.java +++ b/src/main/java/com/project/core/controller/PlanController.java @@ -14,11 +14,11 @@ @RequestMapping("/plan") public class PlanController { - private final PlanService planService; + private final PlanService planService; - @PostMapping("/change") - public ResponseEntity changePlan(@RequestBody PlanChangeRequest request) { - planService.changePlan(request.subId(), request.planId()); - return ResponseEntity.ok().build(); - } + @PostMapping("/change") + public ResponseEntity changePlan(@RequestBody PlanChangeRequest request) { + planService.changePlan(request.subId(), request.planId()); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/project/core/controller/UserController.java b/src/main/java/com/project/core/controller/UserController.java index 2e0ac69..08a8000 100644 --- a/src/main/java/com/project/core/controller/UserController.java +++ b/src/main/java/com/project/core/controller/UserController.java @@ -1,16 +1,14 @@ package com.project.core.controller; +import com.project.core.controller.dto.request.FindUserRequest; +import com.project.core.infra.entity.customer.Customer; +import com.project.core.service.UserService; +import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.project.core.controller.dto.Request.FindUserRequest; -import com.project.core.infra.entity.customer.Customer; -import com.project.core.service.UserService; - -import lombok.RequiredArgsConstructor; - @RestController @RequestMapping("/user") @RequiredArgsConstructor @@ -19,8 +17,7 @@ public class UserController { @GetMapping public Long save(@RequestBody FindUserRequest request) { - Customer customer = userService.loadByContactEnc(request.contactEnc()); - return customer.getCustomerId(); + Customer customer = userService.loadByContactEnc(request.contactEnc()); + return customer.getCustomerId(); } - } diff --git a/src/main/java/com/project/core/controller/dto/PlanChangeRequest.java b/src/main/java/com/project/core/controller/dto/PlanChangeRequest.java index 8056dbe..85ed90a 100644 --- a/src/main/java/com/project/core/controller/dto/PlanChangeRequest.java +++ b/src/main/java/com/project/core/controller/dto/PlanChangeRequest.java @@ -1,6 +1,3 @@ package com.project.core.controller.dto; -public record PlanChangeRequest( - Long subId, - Long planId -) {} +public record PlanChangeRequest(Long subId, Long planId) {} diff --git a/src/main/java/com/project/core/controller/dto/Request/ChangeEmailRequest.java b/src/main/java/com/project/core/controller/dto/Request/ChangeEmailRequest.java index 3874939..fcaebeb 100644 --- a/src/main/java/com/project/core/controller/dto/Request/ChangeEmailRequest.java +++ b/src/main/java/com/project/core/controller/dto/Request/ChangeEmailRequest.java @@ -1,6 +1,3 @@ -package com.project.core.controller.dto.Request; +package com.project.core.controller.dto.request; -public record ChangeEmailRequest( - String emailEnc -) { -} \ No newline at end of file +public record ChangeEmailRequest(String emailEnc) {} diff --git a/src/main/java/com/project/core/controller/dto/Request/ChangeGradeRequest.java b/src/main/java/com/project/core/controller/dto/Request/ChangeGradeRequest.java index e5d0be4..c7fc510 100644 --- a/src/main/java/com/project/core/controller/dto/Request/ChangeGradeRequest.java +++ b/src/main/java/com/project/core/controller/dto/Request/ChangeGradeRequest.java @@ -1,7 +1,5 @@ -package com.project.core.controller.dto.Request; +package com.project.core.controller.dto.request; import com.project.core.infra.entity.customer.enums.Grade; -public record ChangeGradeRequest(Grade grade) { - -} +public record ChangeGradeRequest(Grade grade) {} diff --git a/src/main/java/com/project/core/controller/dto/Request/FindUserRequest.java b/src/main/java/com/project/core/controller/dto/Request/FindUserRequest.java index 904c00d..f985453 100644 --- a/src/main/java/com/project/core/controller/dto/Request/FindUserRequest.java +++ b/src/main/java/com/project/core/controller/dto/Request/FindUserRequest.java @@ -1,7 +1,3 @@ -package com.project.core.controller.dto.Request; - -public record FindUserRequest( - String contactEnc -) { -} +package com.project.core.controller.dto.request; +public record FindUserRequest(String contactEnc) {} diff --git a/src/main/java/com/project/core/controller/dto/Request/SaveExampleRequest.java b/src/main/java/com/project/core/controller/dto/Request/SaveExampleRequest.java index ec7a805..a14a344 100644 --- a/src/main/java/com/project/core/controller/dto/Request/SaveExampleRequest.java +++ b/src/main/java/com/project/core/controller/dto/Request/SaveExampleRequest.java @@ -1,7 +1,3 @@ -package com.project.core.controller.dto.Request; +package com.project.core.controller.dto.request; -public record SaveExampleRequest( - String exampleName, - String exampleContent -) { -} +public record SaveExampleRequest(String exampleName, String exampleContent) {} diff --git a/src/main/java/com/project/core/controller/dto/response/ChangeEmailResponse.java b/src/main/java/com/project/core/controller/dto/response/ChangeEmailResponse.java index 82dee1f..0ee4374 100644 --- a/src/main/java/com/project/core/controller/dto/response/ChangeEmailResponse.java +++ b/src/main/java/com/project/core/controller/dto/response/ChangeEmailResponse.java @@ -1,5 +1,3 @@ package com.project.core.controller.dto.response; -public record ChangeEmailResponse(String emailEnc) { - -} +public record ChangeEmailResponse(String emailEnc) {} diff --git a/src/main/java/com/project/core/controller/dto/response/ChangeGradeResponse.java b/src/main/java/com/project/core/controller/dto/response/ChangeGradeResponse.java index 76a0b9f..31d5cd6 100644 --- a/src/main/java/com/project/core/controller/dto/response/ChangeGradeResponse.java +++ b/src/main/java/com/project/core/controller/dto/response/ChangeGradeResponse.java @@ -2,6 +2,4 @@ import com.project.core.infra.entity.customer.enums.Grade; -public record ChangeGradeResponse(Grade grade) { - -} +public record ChangeGradeResponse(Grade grade) {} diff --git a/src/main/java/com/project/core/infra/entity/customer/Customer.java b/src/main/java/com/project/core/infra/entity/customer/Customer.java index 8f71ff4..03083d2 100644 --- a/src/main/java/com/project/core/infra/entity/customer/Customer.java +++ b/src/main/java/com/project/core/infra/entity/customer/Customer.java @@ -1,12 +1,7 @@ package com.project.core.infra.entity.customer; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - -import com.project.core.infra.entity.subscription.Subscription; import com.project.core.infra.entity.customer.enums.Grade; - +import com.project.core.infra.entity.subscription.Subscription; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -17,6 +12,9 @@ import jakarta.persistence.Id; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -26,38 +24,38 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "customer") public class Customer { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "customer_id") - private Long customerId; - - @Column(name = "name", nullable = false) - private String name; - - @Column(name = "contact_enc", nullable = false) //암호화된 전화번호 - private String contactEnc; - - @Column(name = "email_enc", nullable = false) //암호화된 이메일 - private String emailEnc; - - @Enumerated(EnumType.STRING) - @Column(name = "grade", nullable = false, length = 20) - private Grade grade; - - @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt; - - @Column(name = "is_deleted", nullable = false) - private Boolean isDeleted; - - @OneToMany(mappedBy = "customer", cascade = CascadeType.ALL) - private List subscriptionHistory = new ArrayList<>(); - - - public void changeEmailEnc(String emailEnc) { - this.emailEnc = emailEnc; - } - public void changeGrade(Grade grade) { - this.grade = grade; - } + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "customer_id") + private Long customerId; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "contact_enc", nullable = false) // 암호화된 전화번호 + private String contactEnc; + + @Column(name = "email_enc", nullable = false) // 암호화된 이메일 + private String emailEnc; + + @Enumerated(EnumType.STRING) + @Column(name = "grade", nullable = false, length = 20) + private Grade grade; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted; + + @OneToMany(mappedBy = "customer", cascade = CascadeType.ALL) + private List subscriptionHistory = new ArrayList<>(); + + public void changeEmailEnc(String emailEnc) { + this.emailEnc = emailEnc; + } + + public void changeGrade(Grade grade) { + this.grade = grade; + } } diff --git a/src/main/java/com/project/core/infra/entity/customer/enums/Grade.java b/src/main/java/com/project/core/infra/entity/customer/enums/Grade.java index de56891..6e385f2 100644 --- a/src/main/java/com/project/core/infra/entity/customer/enums/Grade.java +++ b/src/main/java/com/project/core/infra/entity/customer/enums/Grade.java @@ -1,7 +1,7 @@ package com.project.core.infra.entity.customer.enums; public enum Grade { - GENERAL // 일반 - ,VIP - ,VVIP + GENERAL, // 일반 + VIP, + VVIP } diff --git a/src/main/java/com/project/core/infra/entity/plan/Plan.java b/src/main/java/com/project/core/infra/entity/plan/Plan.java index b4ee04c..55f66ef 100644 --- a/src/main/java/com/project/core/infra/entity/plan/Plan.java +++ b/src/main/java/com/project/core/infra/entity/plan/Plan.java @@ -1,7 +1,12 @@ package com.project.core.infra.entity.plan; import com.project.core.infra.entity.plan.enums.AllowancePeriod; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -12,20 +17,20 @@ @Table(name = "plan") public class Plan { - @Id - @Column(name = "plan_id") - private Long planId; + @Id + @Column(name = "plan_id") + private Long planId; - @Column(name = "plan_name", nullable = false, length = 30) - private String planName; + @Column(name = "plan_name", nullable = false, length = 30) + private String planName; - @Column(name = "plan_base_fee", nullable = false) - private Integer planBaseFee; + @Column(name = "plan_base_fee", nullable = false) + private Integer planBaseFee; - @Column(name = "allowance_amount", nullable = false) - private Long allowanceAmount; // MB 단위, -1은 무제한 + @Column(name = "allowance_amount", nullable = false) + private Long allowanceAmount; // MB 단위, -1은 무제한 - @Enumerated(EnumType.STRING) - @Column(name = "allowance_period", nullable = false, length = 10) - private AllowancePeriod allowancePeriod; // MONTH / DAY + @Enumerated(EnumType.STRING) + @Column(name = "allowance_period", nullable = false, length = 10) + private AllowancePeriod allowancePeriod; // MONTH / DAY } diff --git a/src/main/java/com/project/core/infra/entity/plan/SubscriptionPlan.java b/src/main/java/com/project/core/infra/entity/plan/SubscriptionPlan.java index 09790e4..df00a53 100644 --- a/src/main/java/com/project/core/infra/entity/plan/SubscriptionPlan.java +++ b/src/main/java/com/project/core/infra/entity/plan/SubscriptionPlan.java @@ -1,53 +1,60 @@ package com.project.core.infra.entity.plan; import com.project.core.infra.entity.subscription.Subscription; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import java.time.LocalDateTime; - @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "subscription_plan") public class SubscriptionPlan { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "sp_id") - private Long spId; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "sub_id", nullable = false) - private Subscription subscription; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "plan_id", nullable = false) - private Plan plan; - - @Column(name = "cost", nullable = false) - private Integer cost; - - @Column(name = "created_date", nullable = false) - private LocalDateTime createdDate; - - @Column(name = "left_date", nullable = false) - private LocalDateTime leftDate; - - @Builder - public SubscriptionPlan(Subscription subscription, Plan plan) { - this.subscription = subscription; - this.plan = plan; - this.cost = plan.getPlanBaseFee(); // 요금제 가격을 스냅샷으로 저장 - this.createdDate = LocalDateTime.now(); - this.leftDate = LocalDateTime.of(9999, 12, 31, 23, 59, 59); - } - - // 요금제 해지(만료) 처리 메소드 - public void expire() { - this.leftDate = LocalDateTime.now(); - } + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "sp_id") + private Long spId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sub_id", nullable = false) + private Subscription subscription; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "plan_id", nullable = false) + private Plan plan; + + @Column(name = "cost", nullable = false) + private Integer cost; + + @Column(name = "created_date", nullable = false) + private LocalDateTime createdDate; + + @Column(name = "left_date", nullable = false) + private LocalDateTime leftDate; + + @Builder + public SubscriptionPlan(Subscription subscription, Plan plan) { + this.subscription = subscription; + this.plan = plan; + this.cost = plan.getPlanBaseFee(); // 요금제 가격을 스냅샷으로 저장 + this.createdDate = LocalDateTime.now(); + this.leftDate = LocalDateTime.of(9999, 12, 31, 23, 59, 59); + } + + // 요금제 해지(만료) 처리 메소드 + public void expire() { + this.leftDate = LocalDateTime.now(); + } } diff --git a/src/main/java/com/project/core/infra/entity/plan/enums/AllowancePeriod.java b/src/main/java/com/project/core/infra/entity/plan/enums/AllowancePeriod.java index 3bf79c1..ab95503 100644 --- a/src/main/java/com/project/core/infra/entity/plan/enums/AllowancePeriod.java +++ b/src/main/java/com/project/core/infra/entity/plan/enums/AllowancePeriod.java @@ -1,6 +1,6 @@ package com.project.core.infra.entity.plan.enums; public enum AllowancePeriod { - MONTH, - DAY + MONTH, + DAY } diff --git a/src/main/java/com/project/core/infra/entity/subscription/Subscription.java b/src/main/java/com/project/core/infra/entity/subscription/Subscription.java index 0ce65e2..931c522 100644 --- a/src/main/java/com/project/core/infra/entity/subscription/Subscription.java +++ b/src/main/java/com/project/core/infra/entity/subscription/Subscription.java @@ -4,15 +4,25 @@ import com.project.core.infra.entity.plan.SubscriptionPlan; import com.project.core.infra.entity.subscription.enums.SubscriptionStatus; import com.project.core.infra.entity.vas.SubscriptionVas; - -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @@ -20,37 +30,37 @@ @Table(name = "subscription") public class Subscription { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "sub_id") - private Long subId; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "sub_id") + private Long subId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "customer_id", nullable = false) + private Customer customer; + + @Column(name = "phone_number", nullable = false) + private String phoneNumber; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "customer_id", nullable = false) - private Customer customer; + @Column(name = "start_date", nullable = false) + private LocalDateTime startDate; - @Column(name = "phone_number", nullable = false) - private String phoneNumber; + @Column(name = "end_date") + private LocalDateTime endDate; - @Column(name = "start_date", nullable = false) - private LocalDateTime startDate; + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 10) + private SubscriptionStatus status; - @Column(name = "end_date") - private LocalDateTime endDate; + // --------------------------------------------------------------------- - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false, length = 10) - private SubscriptionStatus status; - -//--------------------------------------------------------------------- - - // 요금제 이력 (1:N) - @OneToMany(mappedBy = "subscription", cascade = CascadeType.ALL) - private List planHistory = new ArrayList<>(); + // 요금제 이력 (1:N) + @OneToMany(mappedBy = "subscription", cascade = CascadeType.ALL) + private List planHistory = new ArrayList<>(); - // 부가서비스 이력 (1:N) - @OneToMany(mappedBy = "subscription", cascade = CascadeType.ALL) - private List vasHistory = new ArrayList<>(); + // 부가서비스 이력 (1:N) + @OneToMany(mappedBy = "subscription", cascade = CascadeType.ALL) + private List vasHistory = new ArrayList<>(); - // 할인 이력 (1:N) + // 할인 이력 (1:N) } diff --git a/src/main/java/com/project/core/infra/entity/subscription/enums/SubscriptionStatus.java b/src/main/java/com/project/core/infra/entity/subscription/enums/SubscriptionStatus.java index af88641..9a14d82 100644 --- a/src/main/java/com/project/core/infra/entity/subscription/enums/SubscriptionStatus.java +++ b/src/main/java/com/project/core/infra/entity/subscription/enums/SubscriptionStatus.java @@ -1,7 +1,7 @@ package com.project.core.infra.entity.subscription.enums; public enum SubscriptionStatus { - ACTIVE, // 사용중 - SUSPENDED, // 정지 - TERMINATED // 해지됨 + ACTIVE, // 사용중 + SUSPENDED, // 정지 + TERMINATED // 해지됨 } diff --git a/src/main/java/com/project/core/infra/entity/vas/SubscriptionVas.java b/src/main/java/com/project/core/infra/entity/vas/SubscriptionVas.java index 2b358a8..72f4c41 100644 --- a/src/main/java/com/project/core/infra/entity/vas/SubscriptionVas.java +++ b/src/main/java/com/project/core/infra/entity/vas/SubscriptionVas.java @@ -1,18 +1,24 @@ package com.project.core.infra.entity.vas; import com.project.core.infra.entity.subscription.Subscription; - -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; @Entity public class SubscriptionVas { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "sv_id") - private Long svId; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "sub_id", nullable = false) - private Subscription subscription; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "sv_id") + private Long svId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sub_id", nullable = false) + private Subscription subscription; } diff --git a/src/main/java/com/project/core/infra/repository/plan/PlanRepository.java b/src/main/java/com/project/core/infra/repository/plan/PlanRepository.java index 005feb3..7416b59 100644 --- a/src/main/java/com/project/core/infra/repository/plan/PlanRepository.java +++ b/src/main/java/com/project/core/infra/repository/plan/PlanRepository.java @@ -3,5 +3,4 @@ import com.project.core.infra.entity.plan.Plan; import org.springframework.data.jpa.repository.JpaRepository; -public interface PlanRepository extends JpaRepository { -} +public interface PlanRepository extends JpaRepository {} diff --git a/src/main/java/com/project/core/infra/repository/plan/SubscriptionPlanRepository.java b/src/main/java/com/project/core/infra/repository/plan/SubscriptionPlanRepository.java index fbe1bae..8e84dee 100644 --- a/src/main/java/com/project/core/infra/repository/plan/SubscriptionPlanRepository.java +++ b/src/main/java/com/project/core/infra/repository/plan/SubscriptionPlanRepository.java @@ -1,15 +1,16 @@ package com.project.core.infra.repository.plan; import com.project.core.infra.entity.plan.SubscriptionPlan; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.util.Optional; - public interface SubscriptionPlanRepository extends JpaRepository { - // 해당 회선이 현재 사용 중인 요금제 조회 (leftDate가 현재보다 미래인 것) - @Query("SELECT sp from SubscriptionPlan sp WHERE sp.subscription.subId = :subId AND sp.leftDate > CURRENT_TIMESTAMP") - Optional findActivePlanBySubId(@Param("subId") Long subId); + // 해당 회선이 현재 사용 중인 요금제 조회 (leftDate가 현재보다 미래인 것) + @Query( + "SELECT sp from SubscriptionPlan sp " + + "WHERE sp.subscription.subId = :subId AND sp.leftDate > CURRENT_TIMESTAMP") + Optional findActivePlanBySubId(@Param("subId") Long subId); } diff --git a/src/main/java/com/project/core/infra/repository/subscription/SubscriptionRepository.java b/src/main/java/com/project/core/infra/repository/subscription/SubscriptionRepository.java index a6eb5e2..02e6b51 100644 --- a/src/main/java/com/project/core/infra/repository/subscription/SubscriptionRepository.java +++ b/src/main/java/com/project/core/infra/repository/subscription/SubscriptionRepository.java @@ -3,5 +3,4 @@ import com.project.core.infra.entity.subscription.Subscription; import org.springframework.data.jpa.repository.JpaRepository; -public interface SubscriptionRepository extends JpaRepository { -} +public interface SubscriptionRepository extends JpaRepository {} diff --git a/src/main/java/com/project/core/infra/repository/user/UserRepository.java b/src/main/java/com/project/core/infra/repository/user/UserRepository.java index a4ab855..3a66227 100644 --- a/src/main/java/com/project/core/infra/repository/user/UserRepository.java +++ b/src/main/java/com/project/core/infra/repository/user/UserRepository.java @@ -1,12 +1,9 @@ package com.project.core.infra.repository.user; +import com.project.core.infra.entity.customer.Customer; import java.util.Optional; - import org.springframework.data.jpa.repository.JpaRepository; -import com.project.core.infra.entity.customer.Customer; - - public interface UserRepository extends JpaRepository { - Optional findByContactEnc(String contactEnc); + Optional findByContactEnc(String contactEnc); } diff --git a/src/main/java/com/project/core/service/PlanService.java b/src/main/java/com/project/core/service/PlanService.java index 32c9387..a72554a 100644 --- a/src/main/java/com/project/core/service/PlanService.java +++ b/src/main/java/com/project/core/service/PlanService.java @@ -15,33 +15,32 @@ @Transactional public class PlanService { - private final SubscriptionRepository subscriptionRepository; - private final PlanRepository planRepository; - private final SubscriptionPlanRepository subscriptionPlanRepository; - - /** - * 요금제 변경 (기존 요금제 해지 -> 신규 요금제 가입 - */ - public void changePlan(Long subId, Long newPlanId) { - - // 회선 존재 여부 확인 - Subscription sub = subscriptionRepository.findById(subId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회선입니다.")); - - // 현재 사용 중인 요금제 찾아서 종료 처리 - subscriptionPlanRepository.findActivePlanBySubId(subId) - .ifPresent(SubscriptionPlan::expire); - - // 변경할 새 요금제 정보 조회 - Plan newPlan = planRepository.findById(newPlanId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 요금제입니다.")); - - // 새 요금제 가입 이력 생성 및 저장 - SubscriptionPlan newHistory = SubscriptionPlan.builder() - .subscription(sub) - .plan(newPlan) - .build(); - - subscriptionPlanRepository.save(newHistory); - } + private final SubscriptionRepository subscriptionRepository; + private final PlanRepository planRepository; + private final SubscriptionPlanRepository subscriptionPlanRepository; + + /** 요금제 변경 (기존 요금제 해지 -> 신규 요금제 가입 */ + public void changePlan(Long subId, Long newPlanId) { + + // 회선 존재 여부 확인 + Subscription sub = + subscriptionRepository + .findById(subId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회선입니다.")); + + // 현재 사용 중인 요금제 찾아서 종료 처리 + subscriptionPlanRepository.findActivePlanBySubId(subId).ifPresent(SubscriptionPlan::expire); + + // 변경할 새 요금제 정보 조회 + Plan newPlan = + planRepository + .findById(newPlanId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 요금제입니다.")); + + // 새 요금제 가입 이력 생성 및 저장 + SubscriptionPlan newHistory = + SubscriptionPlan.builder().subscription(sub).plan(newPlan).build(); + + subscriptionPlanRepository.save(newHistory); + } } diff --git a/src/main/java/com/project/core/service/UserService.java b/src/main/java/com/project/core/service/UserService.java index c632ee5..57a1af1 100644 --- a/src/main/java/com/project/core/service/UserService.java +++ b/src/main/java/com/project/core/service/UserService.java @@ -1,39 +1,46 @@ package com.project.core.service; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.project.core.controller.dto.Request.ChangeEmailRequest; -import com.project.core.controller.dto.Request.ChangeGradeRequest; +import com.project.core.controller.dto.request.ChangeEmailRequest; +import com.project.core.controller.dto.request.ChangeGradeRequest; import com.project.core.controller.dto.response.ChangeEmailResponse; import com.project.core.controller.dto.response.ChangeGradeResponse; import com.project.core.infra.entity.customer.Customer; import com.project.core.infra.repository.user.UserRepository; - import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -@Service @RequiredArgsConstructor +@Service +@RequiredArgsConstructor public class UserService { - private final UserRepository userRepository; - - @Transactional - public Customer loadByContactEnc(String contactEnc) { - return userRepository.findByContactEnc(contactEnc).orElseThrow(()->new IllegalArgumentException("사용자를 찾을 수 없습니다")); - } - - @Transactional - public ChangeEmailResponse changeEmailEnc(Long userId, ChangeEmailRequest request) { - Customer customer = userRepository.findById(userId).orElseThrow(()->new IllegalArgumentException("사용자를 찾을 수 없습니다")); + private final UserRepository userRepository; + + @Transactional + public Customer loadByContactEnc(String contactEnc) { + return userRepository + .findByContactEnc(contactEnc) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다")); + } + + @Transactional + public ChangeEmailResponse changeEmailEnc(Long userId, ChangeEmailRequest request) { + Customer customer = + userRepository + .findById(userId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다")); + + customer.changeEmailEnc(request.emailEnc()); + return new ChangeEmailResponse(customer.getEmailEnc()); + } - customer.changeEmailEnc(request.emailEnc()); - return new ChangeEmailResponse(customer.getEmailEnc()); - } - - @Transactional - public ChangeGradeResponse changeUserGrade(Long userId, ChangeGradeRequest request) { - Customer customer = userRepository.findById(userId).orElseThrow(()->new IllegalArgumentException("사용자를 찾을 수 없습니다")); + @Transactional + public ChangeGradeResponse changeUserGrade(Long userId, ChangeGradeRequest request) { + Customer customer = + userRepository + .findById(userId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다")); - customer.changeGrade(request.grade()); - return new ChangeGradeResponse(customer.getGrade()); - } + customer.changeGrade(request.grade()); + return new ChangeGradeResponse(customer.getGrade()); + } } diff --git a/src/main/java/com/project/global/config/RedisConfig.java b/src/main/java/com/project/global/config/RedisConfig.java index 0d51ea2..d0df546 100644 --- a/src/main/java/com/project/global/config/RedisConfig.java +++ b/src/main/java/com/project/global/config/RedisConfig.java @@ -1,23 +1,23 @@ package com.project.global.config; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -//import org.springframework.data.redis.connection.RedisConnectionFactory; -//import org.springframework.data.redis.core.RedisTemplate; -//import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; -//import org.springframework.data.redis.serializer.StringRedisSerializer; + +// import org.springframework.data.redis.connection.RedisConnectionFactory; +// import org.springframework.data.redis.core.RedisTemplate; +// import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +// import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration public class RedisConfig { -// @Bean -// public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { -// RedisTemplate template = new RedisTemplate<>(); -// template.setConnectionFactory(connectionFactory); -// template.setKeySerializer(new StringRedisSerializer()); -// template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); -// template.setHashKeySerializer(new StringRedisSerializer()); -// template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); -// return template; -// } + // @Bean + // public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + // RedisTemplate template = new RedisTemplate<>(); + // template.setConnectionFactory(connectionFactory); + // template.setKeySerializer(new StringRedisSerializer()); + // template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + // template.setHashKeySerializer(new StringRedisSerializer()); + // template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + // return template; + // } } diff --git a/src/main/java/com/project/global/exception/ApplicationException.java b/src/main/java/com/project/global/exception/ApplicationException.java index 85c8d56..38acf76 100644 --- a/src/main/java/com/project/global/exception/ApplicationException.java +++ b/src/main/java/com/project/global/exception/ApplicationException.java @@ -4,7 +4,7 @@ public class ApplicationException extends BaseException { - public ApplicationException(BaseErrorCode code) { - super(code); - } + public ApplicationException(BaseErrorCode code) { + super(code); + } } diff --git a/src/main/java/com/project/global/exception/BaseException.java b/src/main/java/com/project/global/exception/BaseException.java index 207343d..9f50515 100644 --- a/src/main/java/com/project/global/exception/BaseException.java +++ b/src/main/java/com/project/global/exception/BaseException.java @@ -6,18 +6,18 @@ @Getter public abstract class BaseException extends RuntimeException { - private final BaseErrorCode code; + private final BaseErrorCode code; - protected BaseException(BaseErrorCode code) { - super(code.getMessage()); - this.code = code; - } + protected BaseException(BaseErrorCode code) { + super(code.getMessage()); + this.code = code; + } - public static T from(BaseErrorCode code, Class exceptionClass) { - try { - return exceptionClass.getConstructor(BaseErrorCode.class).newInstance(code); - } catch (Exception e) { - throw new RuntimeException("Could not create exception instance", e); - } + public static T from(BaseErrorCode code, Class exceptionClass) { + try { + return exceptionClass.getConstructor(BaseErrorCode.class).newInstance(code); + } catch (Exception e) { + throw new RuntimeException("Could not create exception instance", e); } + } } diff --git a/src/main/java/com/project/global/exception/ExceptionAdvice.java b/src/main/java/com/project/global/exception/ExceptionAdvice.java index 6aa53d6..7705467 100644 --- a/src/main/java/com/project/global/exception/ExceptionAdvice.java +++ b/src/main/java/com/project/global/exception/ExceptionAdvice.java @@ -4,47 +4,41 @@ import com.project.global.exception.code.domain.GlobalErrorCode; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; - -import org.springframework.http.ProblemDetail; - @Slf4j @RestControllerAdvice public class ExceptionAdvice extends ResponseEntityExceptionHandler { - @ExceptionHandler(BaseException.class) - public ResponseEntity handleBaseException(BaseException e, HttpServletRequest request) { - BaseErrorCode code = e.getCode(); - log.error("[BaseException] {} - {}", code.name(), code.getMessage()); + @ExceptionHandler(BaseException.class) + public ResponseEntity handleBaseException(BaseException e, HttpServletRequest request) { + BaseErrorCode code = e.getCode(); + log.error("[BaseException] {} - {}", code.name(), code.getMessage()); - ProblemDetail problem = ProblemDetail.forStatus(code.getHttpStatus()); - problem.setTitle(code.name()); - problem.setDetail(code.getMessage()); - problem.setProperty("code", code.getCustomCode()); + ProblemDetail problem = ProblemDetail.forStatus(code.getHttpStatus()); + problem.setTitle(code.name()); + problem.setDetail(code.getMessage()); + problem.setProperty("code", code.getCustomCode()); - return ResponseEntity - .status(code.getHttpStatus()) - .body(problem); - } + return ResponseEntity.status(code.getHttpStatus()).body(problem); + } - @ExceptionHandler(Exception.class) - public ResponseEntity handleUnhandledException(Exception e, WebRequest request) { - log.error("[Exception] Unhandled", e); + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnhandledException(Exception e, WebRequest request) { + log.error("[Exception] Unhandled", e); - GlobalErrorCode code = GlobalErrorCode.INTERNAL_SERVER_ERROR; + GlobalErrorCode code = GlobalErrorCode.INTERNAL_SERVER_ERROR; - ProblemDetail problem = ProblemDetail.forStatus(code.getHttpStatus()); - problem.setTitle(code.name()); - problem.setDetail(code.getMessage()); - problem.setProperty("code", code.getCustomCode()); + ProblemDetail problem = ProblemDetail.forStatus(code.getHttpStatus()); + problem.setTitle(code.name()); + problem.setDetail(code.getMessage()); + problem.setProperty("code", code.getCustomCode()); - return ResponseEntity - .status(code.getHttpStatus()) - .body(problem); - } + return ResponseEntity.status(code.getHttpStatus()).body(problem); + } } diff --git a/src/main/java/com/project/global/exception/code/domain/BaseErrorCode.java b/src/main/java/com/project/global/exception/code/domain/BaseErrorCode.java index cb78e09..f0dafbe 100644 --- a/src/main/java/com/project/global/exception/code/domain/BaseErrorCode.java +++ b/src/main/java/com/project/global/exception/code/domain/BaseErrorCode.java @@ -3,8 +3,11 @@ import org.springframework.http.HttpStatus; public interface BaseErrorCode { - String name(); // ⭐ 핵심 - HttpStatus getHttpStatus(); - String getMessage(); - String getCustomCode(); + String name(); // ⭐ 핵심 + + HttpStatus getHttpStatus(); + + String getMessage(); + + String getCustomCode(); } diff --git a/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java b/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java index 9690929..66c4dc6 100644 --- a/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java +++ b/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java @@ -1,19 +1,17 @@ package com.project.global.exception.code.domain; -import org.springframework.http.HttpStatus; - import lombok.Getter; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; @Getter @RequiredArgsConstructor -public enum GlobalErrorCode implements BaseErrorCode{ - - EXAMPLE_NOT_FOUND(HttpStatus.BAD_REQUEST, "EXAMPLE_001", "Example을 찾을 수 없습니다"), - INTERNAL_SERVER_ERROR(HttpStatus.BAD_REQUEST, "EXAMPLE_001", "Example을 찾을 수 없습니다"), - ; +public enum GlobalErrorCode implements BaseErrorCode { + EXAMPLE_NOT_FOUND(HttpStatus.BAD_REQUEST, "EXAMPLE_001", "Example을 찾을 수 없습니다"), + INTERNAL_SERVER_ERROR(HttpStatus.BAD_REQUEST, "EXAMPLE_001", "Example을 찾을 수 없습니다"), + ; - private final HttpStatus httpStatus; - private final String customCode; - private final String message; + private final HttpStatus httpStatus; + private final String customCode; + private final String message; } From 9f3c5ce87e508f93fce1938006b62e8b7e69e23a Mon Sep 17 00:00:00 2001 From: swthewhite Date: Wed, 14 Jan 2026 10:05:58 +0900 Subject: [PATCH 5/5] =?UTF-8?q?UPLUS-73=20cicd:=20CD=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20Dockerfile.deploy=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile.deploy | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 Dockerfile.deploy diff --git a/Dockerfile.deploy b/Dockerfile.deploy new file mode 100644 index 0000000..78a420f --- /dev/null +++ b/Dockerfile.deploy @@ -0,0 +1,17 @@ +# ============================================================================ +# Dockerfile for CI/CD Pipeline (Pre-built JAR) +# ============================================================================ +# 이 Dockerfile은 CI 파이프라인에서 이미 빌드된 JAR 파일을 사용합니다. +# 로컬 개발 시에는 기존 Dockerfile (멀티스테이지 빌드)을 사용하세요. +# ============================================================================ + +FROM eclipse-temurin:17-jre + +WORKDIR /app + +# CI에서 빌드된 JAR 파일 복사 +COPY build/libs/*.jar app.jar + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "app.jar"]