diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml new file mode 100644 index 0000000..ff41572 --- /dev/null +++ b/.github/workflows/deploy-backend.yml @@ -0,0 +1,169 @@ +name: Deploy Backend + +on: + push: + branches: [ "main", "deploy/aws-ec2" ] + paths: [ 'backend/**', 'database/**', 'compose.prod.yml', 'scripts/deploy-ecr-service.sh', '.github/workflows/deploy-backend.yml' ] + workflow_dispatch: + inputs: + stable_tag: + description: 'release-<40자리 SHA>를 stable 태그로 이동' + required: false + type: string + +concurrency: + group: ec2-production-deployment + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +jobs: + ci-backend: + name: Backend CI (Spotless & Test) + runs-on: ubuntu-latest + if: github.event_name == 'push' || inputs.stable_tag == '' + services: + mysql: + image: mysql:8.0 + env: + MYSQL_DATABASE: snowthing_test + MYSQL_USER: snowuser + MYSQL_PASSWORD: snowthing_pass_2026! + MYSQL_ROOT_PASSWORD: snowthing_root_2026! + ports: [ '3306:3306' ] + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5 + env: + SNOWTHING_DB_USERNAME: snowuser + SNOWTHING_DB_PASSWORD: snowthing_pass_2026! + SNOWTHING_TEST_DB_URL: jdbc:mysql://localhost:3306/snowthing_test?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul + SNOWTHING_TEST_DB_USERNAME: snowuser + SNOWTHING_TEST_DB_PASSWORD: snowthing_pass_2026! + defaults: + run: + working-directory: ./backend + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-java@v6 + with: + java-version: '21' + distribution: 'temurin' + - uses: gradle/actions/setup-gradle@v6 + - run: chmod +x gradlew + - run: ./gradlew spotlessCheck + - run: ./gradlew test build -x spotlessCheck + + deploy-backend: + name: Build, Push and Deploy Backend Digest + needs: [ci-backend] + runs-on: ubuntu-latest + timeout-minutes: 30 + if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/deploy/aws-ec2') && (github.event_name == 'push' || inputs.stable_tag == '') + steps: + - uses: actions/checkout@v7 + - name: Configure AWS credentials with OIDC + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + + - name: Log in to Amazon ECR + id: ecr + run: | + set -euo pipefail + ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) + REGISTRY="${ACCOUNT_ID}.dkr.ecr.${{ vars.AWS_REGION }}.amazonaws.com" + aws ecr get-login-password --region "${{ vars.AWS_REGION }}" | docker login --username AWS --password-stdin "$REGISTRY" + echo "registry=$REGISTRY" >> "$GITHUB_OUTPUT" + + - name: Build or reuse commit image + id: image + env: + REGISTRY: ${{ steps.ecr.outputs.registry }} + REPOSITORY: ${{ vars.ECR_BACKEND_REPOSITORY }} + COMMIT_SHA: ${{ github.sha }} + run: | + set -euo pipefail + CANDIDATE_TAG="candidate-${COMMIT_SHA}" + RELEASE_TAG="release-${COMMIT_SHA}" + if aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageTag="$RELEASE_TAG" >/dev/null 2>&1; then + SOURCE_TAG="$RELEASE_TAG"; PROMOTE=false + elif aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageTag="$CANDIDATE_TAG" >/dev/null 2>&1; then + SOURCE_TAG="$CANDIDATE_TAG"; PROMOTE=true + else + docker build --tag "$REGISTRY/$REPOSITORY:$CANDIDATE_TAG" ./backend + docker push "$REGISTRY/$REPOSITORY:$CANDIDATE_TAG" + SOURCE_TAG="$CANDIDATE_TAG"; PROMOTE=true + fi + DIGEST=$(aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageTag="$SOURCE_TAG" --query 'imageDetails[0].imageDigest' --output text) + echo "image_uri=$REGISTRY/$REPOSITORY@$DIGEST" >> "$GITHUB_OUTPUT" + echo "source_tag=$SOURCE_TAG" >> "$GITHUB_OUTPUT" + echo "promote=$PROMOTE" >> "$GITHUB_OUTPUT" + + - name: Deploy backend digest via SSM + env: + INSTANCE_ID: ${{ vars.EC2_INSTANCE_ID }} + DEPLOY_BRANCH: ${{ github.ref_name }} + IMAGE_URI: ${{ steps.image.outputs.image_uri }} + AWS_REGION: ${{ vars.AWS_REGION }} + run: | + set -euo pipefail + PARAMETERS=$(jq -n --arg branch "$DEPLOY_BRANCH" --arg image "$IMAGE_URI" --arg region "$AWS_REGION" '{commands: [ + "set -e", "cd /home/ubuntu/snowthing", + "git -c safe.directory=/home/ubuntu/snowthing fetch origin \"" + $branch + "\"", + "git -c safe.directory=/home/ubuntu/snowthing reset --hard \"origin/" + $branch + "\"", + "chmod +x scripts/deploy-ecr-service.sh", + "scripts/deploy-ecr-service.sh backend \"" + $image + "\" \"" + $region + "\"" + ]}') + COMMAND_ID=$(aws ssm send-command --instance-ids "$INSTANCE_ID" --document-name AWS-RunShellScript --timeout-seconds 900 --parameters "$PARAMETERS" --comment "Deploy backend digest from ${GITHUB_REPOSITORY}@${GITHUB_SHA}" --query 'Command.CommandId' --output text) + echo "SSM command ID: $COMMAND_ID" + STATUS=Pending + for _ in $(seq 1 180); do + STATUS=$(aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID" --query Status --output text 2>/dev/null || echo Pending) + case "$STATUS" in Success|Cancelled|TimedOut|Failed|Cancelling) break ;; esac + sleep 5 + done + aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID" --query '{Status:Status,StandardOutput:StandardOutputContent,StandardError:StandardErrorContent}' + test "$STATUS" = Success + + - name: Promote successful image to release + if: steps.image.outputs.promote == 'true' + env: + REPOSITORY: ${{ vars.ECR_BACKEND_REPOSITORY }} + SOURCE_TAG: ${{ steps.image.outputs.source_tag }} + RELEASE_TAG: release-${{ github.sha }} + run: | + set -euo pipefail + MANIFEST=$(aws ecr batch-get-image --repository-name "$REPOSITORY" --image-ids imageTag="$SOURCE_TAG" --query 'images[0].imageManifest' --output text) + aws ecr put-image --repository-name "$REPOSITORY" --image-tag "$RELEASE_TAG" --image-manifest "$MANIFEST" >/dev/null + aws ecr batch-delete-image --repository-name "$REPOSITORY" --image-ids imageTag="$SOURCE_TAG" >/dev/null + + mark-stable-backend: + name: Mark Backend Release as Stable + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' && inputs.stable_tag != '' + steps: + - name: Configure AWS credentials with OIDC + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + - name: Move all release tags on the digest to one stable tag + env: + REPOSITORY: ${{ vars.ECR_BACKEND_REPOSITORY }} + SOURCE_TAG: ${{ inputs.stable_tag }} + run: | + set -euo pipefail + [[ "$SOURCE_TAG" =~ ^release-[0-9a-f]{40}$ ]] + DIGEST=$(aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageTag="$SOURCE_TAG" --query 'imageDetails[0].imageDigest' --output text) + MANIFEST=$(aws ecr batch-get-image --repository-name "$REPOSITORY" --image-ids imageDigest="$DIGEST" --query 'images[0].imageManifest' --output text) + STABLE_TAG="stable-${SOURCE_TAG#release-}" + aws ecr put-image --repository-name "$REPOSITORY" --image-tag "$STABLE_TAG" --image-manifest "$MANIFEST" >/dev/null + mapfile -t RELEASE_TAGS < <(aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageDigest="$DIGEST" --query 'imageDetails[0].imageTags[?starts_with(@, `release-`)]' --output text | tr '\t' '\n') + for tag in "${RELEASE_TAGS[@]}"; do + [[ -n "$tag" ]] && aws ecr batch-delete-image --repository-name "$REPOSITORY" --image-ids imageTag="$tag" >/dev/null + done + echo "$STABLE_TAG -> $DIGEST" diff --git a/.github/workflows/deploy-frontend.yml b/.github/workflows/deploy-frontend.yml new file mode 100644 index 0000000..8d34db5 --- /dev/null +++ b/.github/workflows/deploy-frontend.yml @@ -0,0 +1,205 @@ +name: Deploy Frontend + +on: + push: + branches: [ "main", "deploy/aws-ec2" ] + paths: [ 'frontend/**', 'compose.prod.yml', 'scripts/deploy-ecr-service.sh', '.github/workflows/deploy-frontend.yml' ] + workflow_dispatch: + inputs: + rollback_tag: + description: '비워 두면 현재 커밋 배포, 입력하면 release-<40자리 SHA> 또는 stable-<40자리 SHA>로 복구' + required: false + type: string + stable_tag: + description: 'release-<40자리 SHA>를 stable 태그로 이동' + required: false + type: string + +concurrency: + group: ec2-production-deployment + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +jobs: + ci-frontend: + name: Frontend CI (Build Verification) + if: github.event_name == 'push' || (inputs.rollback_tag == '' && inputs.stable_tag == '') + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./frontend + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci --legacy-peer-deps + - run: npm run build + + deploy-frontend: + name: Build, Push and Deploy Frontend Digest + needs: [ci-frontend] + runs-on: ubuntu-latest + timeout-minutes: 30 + if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/deploy/aws-ec2') && (github.event_name == 'push' || (inputs.rollback_tag == '' && inputs.stable_tag == '')) + steps: + - uses: actions/checkout@v7 + - name: Configure AWS credentials with OIDC + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + + - name: Log in to Amazon ECR + id: ecr + run: | + set -euo pipefail + ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) + REGISTRY="${ACCOUNT_ID}.dkr.ecr.${{ vars.AWS_REGION }}.amazonaws.com" + aws ecr get-login-password --region "${{ vars.AWS_REGION }}" | docker login --username AWS --password-stdin "$REGISTRY" + echo "registry=$REGISTRY" >> "$GITHUB_OUTPUT" + + - name: Build or reuse commit image + id: image + env: + REGISTRY: ${{ steps.ecr.outputs.registry }} + REPOSITORY: ${{ vars.ECR_FRONTEND_REPOSITORY }} + COMMIT_SHA: ${{ github.sha }} + API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }} + run: | + set -euo pipefail + CANDIDATE_TAG="candidate-${COMMIT_SHA}" + RELEASE_TAG="release-${COMMIT_SHA}" + if aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageTag="$RELEASE_TAG" >/dev/null 2>&1; then + SOURCE_TAG="$RELEASE_TAG"; PROMOTE=false + elif aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageTag="$CANDIDATE_TAG" >/dev/null 2>&1; then + SOURCE_TAG="$CANDIDATE_TAG"; PROMOTE=true + else + docker build --build-arg NEXT_PUBLIC_API_BASE_URL="$API_BASE_URL" --tag "$REGISTRY/$REPOSITORY:$CANDIDATE_TAG" ./frontend + docker push "$REGISTRY/$REPOSITORY:$CANDIDATE_TAG" + SOURCE_TAG="$CANDIDATE_TAG"; PROMOTE=true + fi + DIGEST=$(aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageTag="$SOURCE_TAG" --query 'imageDetails[0].imageDigest' --output text) + echo "image_uri=$REGISTRY/$REPOSITORY@$DIGEST" >> "$GITHUB_OUTPUT" + echo "source_tag=$SOURCE_TAG" >> "$GITHUB_OUTPUT" + echo "promote=$PROMOTE" >> "$GITHUB_OUTPUT" + + - name: Deploy frontend digest via SSM + env: + INSTANCE_ID: ${{ vars.EC2_INSTANCE_ID }} + DEPLOY_BRANCH: ${{ github.ref_name }} + IMAGE_URI: ${{ steps.image.outputs.image_uri }} + AWS_REGION: ${{ vars.AWS_REGION }} + run: | + set -euo pipefail + PARAMETERS=$(jq -n --arg branch "$DEPLOY_BRANCH" --arg image "$IMAGE_URI" --arg region "$AWS_REGION" '{commands: [ + "set -e", "cd /home/ubuntu/snowthing", + "git -c safe.directory=/home/ubuntu/snowthing fetch origin \"" + $branch + "\"", + "git -c safe.directory=/home/ubuntu/snowthing reset --hard \"origin/" + $branch + "\"", + "chmod +x scripts/deploy-ecr-service.sh", + "scripts/deploy-ecr-service.sh frontend \"" + $image + "\" \"" + $region + "\"" + ]}') + COMMAND_ID=$(aws ssm send-command --instance-ids "$INSTANCE_ID" --document-name AWS-RunShellScript --timeout-seconds 900 --parameters "$PARAMETERS" --comment "Deploy frontend digest from ${GITHUB_REPOSITORY}@${GITHUB_SHA}" --query 'Command.CommandId' --output text) + echo "SSM command ID: $COMMAND_ID" + STATUS=Pending + for _ in $(seq 1 180); do + STATUS=$(aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID" --query Status --output text 2>/dev/null || echo Pending) + case "$STATUS" in Success|Cancelled|TimedOut|Failed|Cancelling) break ;; esac + sleep 5 + done + aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID" --query '{Status:Status,StandardOutput:StandardOutputContent,StandardError:StandardErrorContent}' + test "$STATUS" = Success + + - name: Promote successful image to release + if: steps.image.outputs.promote == 'true' + env: + REPOSITORY: ${{ vars.ECR_FRONTEND_REPOSITORY }} + SOURCE_TAG: ${{ steps.image.outputs.source_tag }} + RELEASE_TAG: release-${{ github.sha }} + run: | + set -euo pipefail + MANIFEST=$(aws ecr batch-get-image --repository-name "$REPOSITORY" --image-ids imageTag="$SOURCE_TAG" --query 'images[0].imageManifest' --output text) + aws ecr put-image --repository-name "$REPOSITORY" --image-tag "$RELEASE_TAG" --image-manifest "$MANIFEST" >/dev/null + aws ecr batch-delete-image --repository-name "$REPOSITORY" --image-ids imageTag="$SOURCE_TAG" >/dev/null + + rollback-frontend: + name: Roll Back Frontend to Selected Digest + runs-on: ubuntu-latest + timeout-minutes: 20 + if: github.event_name == 'workflow_dispatch' && inputs.rollback_tag != '' + steps: + - name: Validate rollback tag + env: + ROLLBACK_TAG: ${{ inputs.rollback_tag }} + run: | + set -euo pipefail + [[ "$ROLLBACK_TAG" =~ ^(release|stable)-[0-9a-f]{40}$ ]] + + - name: Configure AWS credentials with OIDC + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + + - name: Resolve rollback digest and deploy via SSM + env: + INSTANCE_ID: ${{ vars.EC2_INSTANCE_ID }} + DEPLOY_BRANCH: ${{ github.ref_name }} + REPOSITORY: ${{ vars.ECR_FRONTEND_REPOSITORY }} + AWS_REGION: ${{ vars.AWS_REGION }} + ROLLBACK_TAG: ${{ inputs.rollback_tag }} + run: | + set -euo pipefail + ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) + REGISTRY="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" + DIGEST=$(aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageTag="$ROLLBACK_TAG" --query 'imageDetails[0].imageDigest' --output text) + IMAGE_URI="$REGISTRY/$REPOSITORY@$DIGEST" + PARAMETERS=$(jq -n --arg branch "$DEPLOY_BRANCH" --arg image "$IMAGE_URI" --arg region "$AWS_REGION" '{commands: [ + "set -e", "cd /home/ubuntu/snowthing", + "git -c safe.directory=/home/ubuntu/snowthing fetch origin \"" + $branch + "\"", + "git -c safe.directory=/home/ubuntu/snowthing reset --hard \"origin/" + $branch + "\"", + "chmod +x scripts/deploy-ecr-service.sh", + "scripts/deploy-ecr-service.sh frontend \"" + $image + "\" \"" + $region + "\"" + ]}') + COMMAND_ID=$(aws ssm send-command --instance-ids "$INSTANCE_ID" --document-name AWS-RunShellScript --timeout-seconds 900 --parameters "$PARAMETERS" --comment "Rollback frontend to $ROLLBACK_TAG" --query 'Command.CommandId' --output text) + echo "SSM command ID: $COMMAND_ID" + STATUS=Pending + for _ in $(seq 1 180); do + STATUS=$(aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID" --query Status --output text 2>/dev/null || echo Pending) + case "$STATUS" in Success|Cancelled|TimedOut|Failed|Cancelling) break ;; esac + sleep 5 + done + aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID" --query '{Status:Status,StandardOutput:StandardOutputContent,StandardError:StandardErrorContent}' + test "$STATUS" = Success + + mark-stable-frontend: + name: Mark Frontend Release as Stable + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' && inputs.stable_tag != '' + steps: + - name: Configure AWS credentials with OIDC + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + - name: Move all release tags on the digest to one stable tag + env: + REPOSITORY: ${{ vars.ECR_FRONTEND_REPOSITORY }} + SOURCE_TAG: ${{ inputs.stable_tag }} + run: | + set -euo pipefail + [[ "$SOURCE_TAG" =~ ^release-[0-9a-f]{40}$ ]] + DIGEST=$(aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageTag="$SOURCE_TAG" --query 'imageDetails[0].imageDigest' --output text) + MANIFEST=$(aws ecr batch-get-image --repository-name "$REPOSITORY" --image-ids imageDigest="$DIGEST" --query 'images[0].imageManifest' --output text) + STABLE_TAG="stable-${SOURCE_TAG#release-}" + aws ecr put-image --repository-name "$REPOSITORY" --image-tag "$STABLE_TAG" --image-manifest "$MANIFEST" >/dev/null + mapfile -t RELEASE_TAGS < <(aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageDigest="$DIGEST" --query 'imageDetails[0].imageTags[?starts_with(@, `release-`)]' --output text | tr '\t' '\n') + for tag in "${RELEASE_TAGS[@]}"; do + [[ -n "$tag" ]] && aws ecr batch-delete-image --repository-name "$REPOSITORY" --image-ids imageTag="$tag" >/dev/null + done + echo "$STABLE_TAG -> $DIGEST" diff --git a/.github/workflows/gemini-review.yml b/.github/workflows/gemini-review.yml index 9d12f17..a2c96b1 100644 --- a/.github/workflows/gemini-review.yml +++ b/.github/workflows/gemini-review.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Run Gemini Review via REST API env: diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index de79e44..ffed6d6 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -17,18 +17,18 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: '21' distribution: 'temurin' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6 - name: Grant execute permission for gradlew run: chmod +x gradlew @@ -66,18 +66,18 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: '21' distribution: 'temurin' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6 - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/.github/workflows/manage-ecr-release.yml b/.github/workflows/manage-ecr-release.yml new file mode 100644 index 0000000..258e5ee --- /dev/null +++ b/.github/workflows/manage-ecr-release.yml @@ -0,0 +1,117 @@ +name: Manage ECR Release + +on: + workflow_dispatch: + inputs: + operation: + description: 실행할 작업 + required: true + type: choice + options: [rollback, mark-stable] + service: + description: 대상 서비스 + required: true + type: choice + options: [backend, frontend] + source_tag: + description: release-<전체 SHA> 또는 stable-<전체 SHA> + required: true + type: string + +concurrency: + group: ec2-production-deployment + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +jobs: + manage-release: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Validate input + env: + OPERATION: ${{ inputs.operation }} + SOURCE_TAG: ${{ inputs.source_tag }} + run: | + set -euo pipefail + if [[ ! "$SOURCE_TAG" =~ ^(release|stable)-[0-9a-f]{40}$ ]]; then + echo "태그 형식은 release-<40자리 SHA> 또는 stable-<40자리 SHA>여야 합니다." >&2 + exit 2 + fi + if [[ "$OPERATION" == "mark-stable" && ! "$SOURCE_TAG" =~ ^release- ]]; then + echo "stable 지정은 release 태그에서만 가능합니다." >&2 + exit 2 + fi + + - uses: actions/checkout@v7 + + - name: Configure AWS credentials with OIDC + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + + - name: Resolve repository and digest + id: image + env: + SERVICE: ${{ inputs.service }} + SOURCE_TAG: ${{ inputs.source_tag }} + run: | + set -euo pipefail + if [[ "$SERVICE" == "backend" ]]; then + REPOSITORY="${{ vars.ECR_BACKEND_REPOSITORY }}" + else + REPOSITORY="${{ vars.ECR_FRONTEND_REPOSITORY }}" + fi + ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) + REGISTRY="${ACCOUNT_ID}.dkr.ecr.${{ vars.AWS_REGION }}.amazonaws.com" + DIGEST=$(aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageTag="$SOURCE_TAG" --query 'imageDetails[0].imageDigest' --output text) + echo "repository=$REPOSITORY" >> "$GITHUB_OUTPUT" + echo "image_uri=$REGISTRY/$REPOSITORY@$DIGEST" >> "$GITHUB_OUTPUT" + + - name: Roll back to selected digest via SSM + if: inputs.operation == 'rollback' + env: + INSTANCE_ID: ${{ vars.EC2_INSTANCE_ID }} + DEPLOY_BRANCH: ${{ github.ref_name }} + IMAGE_URI: ${{ steps.image.outputs.image_uri }} + AWS_REGION: ${{ vars.AWS_REGION }} + SERVICE: ${{ inputs.service }} + run: | + set -euo pipefail + PARAMETERS=$(jq -n --arg branch "$DEPLOY_BRANCH" --arg image "$IMAGE_URI" --arg region "$AWS_REGION" --arg service "$SERVICE" '{commands: [ + "set -e", "cd /home/ubuntu/snowthing", + "git -c safe.directory=/home/ubuntu/snowthing fetch origin \"" + $branch + "\"", + "git -c safe.directory=/home/ubuntu/snowthing reset --hard \"origin/" + $branch + "\"", + "chmod +x scripts/deploy-ecr-service.sh", + "scripts/deploy-ecr-service.sh \"" + $service + "\" \"" + $image + "\" \"" + $region + "\"" + ]}') + COMMAND_ID=$(aws ssm send-command --instance-ids "$INSTANCE_ID" --document-name AWS-RunShellScript --timeout-seconds 900 --parameters "$PARAMETERS" --comment "Rollback $SERVICE to ${{ inputs.source_tag }}" --query 'Command.CommandId' --output text) + STATUS=Pending + for _ in $(seq 1 180); do + STATUS=$(aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID" --query Status --output text 2>/dev/null || echo Pending) + case "$STATUS" in Success|Cancelled|TimedOut|Failed|Cancelling) break ;; esac + sleep 5 + done + aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID" --query '{Status:Status,StandardOutput:StandardOutputContent,StandardError:StandardErrorContent}' + test "$STATUS" = Success + + - name: Move release tag to stable + if: inputs.operation == 'mark-stable' + env: + REPOSITORY: ${{ steps.image.outputs.repository }} + SOURCE_TAG: ${{ inputs.source_tag }} + run: | + set -euo pipefail + STABLE_TAG="stable-${SOURCE_TAG#release-}" + DIGEST=$(aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageTag="$SOURCE_TAG" --query 'imageDetails[0].imageDigest' --output text) + MANIFEST=$(aws ecr batch-get-image --repository-name "$REPOSITORY" --image-ids imageDigest="$DIGEST" --query 'images[0].imageManifest' --output text) + aws ecr put-image --repository-name "$REPOSITORY" --image-tag "$STABLE_TAG" --image-manifest "$MANIFEST" >/dev/null + mapfile -t RELEASE_TAGS < <(aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageDigest="$DIGEST" --query 'imageDetails[0].imageTags[?starts_with(@, `release-`)]' --output text | tr '\t' '\n') + for tag in "${RELEASE_TAGS[@]}"; do + [[ -n "$tag" ]] && aws ecr batch-delete-image --repository-name "$REPOSITORY" --image-ids imageTag="$tag" >/dev/null + done + echo "$STABLE_TAG -> $DIGEST" diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..5aaa511 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,33 @@ +# ========================================== +# 1단계: 빌드 스테이지 (JDK 21) +# ========================================== +FROM eclipse-temurin:21-jdk-jammy AS builder +WORKDIR /app + +# Gradle 래퍼 및 빌드 스크립트 복사 +COPY gradlew . +COPY gradle gradle +COPY build.gradle settings.gradle ./ +RUN chmod +x ./gradlew + +# 소스코드 복사 및 bootJar 빌드 (테스트 제외하여 빠른 배포 빌드) +COPY src src +RUN ./gradlew bootJar -x test --no-daemon + +# ========================================== +# 2단계: 실행 스테이지 (초경량 JRE 21) +# ========================================== +FROM eclipse-temurin:21-jre-jammy AS runner +WORKDIR /app + +# 보안을 위한 비root 시스템 유저 생성 +RUN addgroup --system --gid 1001 spring && adduser --system --uid 1001 --ingroup spring spring +USER spring:spring + +# 빌더에서 생성된 실행 가능한 jar 파일만 추출 +COPY --from=builder --chown=spring:spring /app/build/libs/*.jar app.jar + +EXPOSE 8080 +ENV TZ=Asia/Seoul + +ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "app.jar"] diff --git a/backend/build.gradle b/backend/build.gradle index b48eea1..5c742d3 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -31,11 +31,15 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-jackson' implementation 'com.fasterxml.jackson.core:jackson-databind' - // JPA & DB implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'com.mysql:mysql-connector-j' + // AWS Advanced JDBC Wrapper & RDS/S3 SDK + implementation 'software.amazon.jdbc:aws-advanced-jdbc-wrapper:2.3.7' + implementation 'software.amazon.awssdk:rds:2.25.70' + implementation 'software.amazon.awssdk:s3:2.25.70' + // Security & Session Redis implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-data-redis' diff --git a/backend/src/main/java/com/ikae/snowthing/domain/auth/dto/MemberLoginRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/auth/dto/MemberLoginRequest.java index 545819d..f11e29b 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/auth/dto/MemberLoginRequest.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/auth/dto/MemberLoginRequest.java @@ -16,12 +16,16 @@ public class MemberLoginRequest { @NotBlank(message = "비밀번호는 필수 입력값입니다.") private String password; - private boolean rememberMe = false; // 로그인 상태 유지 (30일 지속 세션 쿠키) + private Boolean rememberMe = Boolean.FALSE; // 로그인 상태 유지 (30일 지속 세션 쿠키) @Builder - public MemberLoginRequest(String email, String password, boolean rememberMe) { + public MemberLoginRequest(String email, String password, Boolean rememberMe) { this.email = email; this.password = password; - this.rememberMe = rememberMe; + this.rememberMe = rememberMe != null ? rememberMe : Boolean.FALSE; + } + + public boolean isRememberMe() { + return Boolean.TRUE.equals(this.rememberMe); } } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/image/controller/ImageController.java b/backend/src/main/java/com/ikae/snowthing/domain/image/controller/ImageController.java new file mode 100644 index 0000000..5549e51 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/image/controller/ImageController.java @@ -0,0 +1,67 @@ +package com.ikae.snowthing.domain.image.controller; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.ikae.snowthing.domain.image.dto.ImageDownloadResponse; +import com.ikae.snowthing.domain.image.dto.ImageUploadResponse; +import com.ikae.snowthing.domain.image.service.ImageService; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; +import com.ikae.snowthing.global.security.CustomUserDetails; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/images") +@RequiredArgsConstructor +public class ImageController { + + private final ImageService imageService; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadImage( + @RequestParam("file") MultipartFile file, + @AuthenticationPrincipal CustomUserDetails userDetails) { + if (userDetails == null) { + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); + } + + ImageUploadResponse response = imageService.uploadImage(file); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping("/**") + public ResponseEntity getImage( + HttpServletRequest request, @AuthenticationPrincipal CustomUserDetails userDetails) { + if (userDetails == null) { + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); + } + + String path = request.getRequestURI(); + String prefix = "/api/v1/images/"; + if (!path.startsWith(prefix)) { + return ResponseEntity.badRequest().build(); + } + + String imageKey = path.substring(prefix.length()); + ImageDownloadResponse download = imageService.getImage(imageKey); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType(download.contentType())); + headers.setCacheControl("public, max-age=86400"); + + return new ResponseEntity<>(download.data(), headers, HttpStatus.OK); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/image/dto/ImageDownloadResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/image/dto/ImageDownloadResponse.java new file mode 100644 index 0000000..c888f9f --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/image/dto/ImageDownloadResponse.java @@ -0,0 +1,12 @@ +package com.ikae.snowthing.domain.image.dto; + +public record ImageDownloadResponse(byte[] data, String contentType) { + public ImageDownloadResponse { + data = data != null ? data.clone() : new byte[0]; + } + + @Override + public byte[] data() { + return data.clone(); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/image/dto/ImageUploadResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/image/dto/ImageUploadResponse.java new file mode 100644 index 0000000..d110119 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/image/dto/ImageUploadResponse.java @@ -0,0 +1,3 @@ +package com.ikae.snowthing.domain.image.dto; + +public record ImageUploadResponse(String imageUrl, String imageKey) {} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/image/service/ImageService.java b/backend/src/main/java/com/ikae/snowthing/domain/image/service/ImageService.java new file mode 100644 index 0000000..273a0b3 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/image/service/ImageService.java @@ -0,0 +1,126 @@ +package com.ikae.snowthing.domain.image.service; + +import java.io.IOException; +import java.util.Set; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import com.ikae.snowthing.domain.image.dto.ImageDownloadResponse; +import com.ikae.snowthing.domain.image.dto.ImageUploadResponse; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.BusinessException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ImageService { + + private static final long MAX_FILE_SIZE = 5 * 1024 * 1024L; // 5MB + private static final Set ALLOWED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "webp"); + private static final Set ALLOWED_MIME_TYPES = + Set.of("image/jpeg", "image/png", "image/webp"); + + private final S3Client s3Client; + + @Value("${cloud.aws.s3.bucket:snowthing-media-00001}") + private String bucketName; + + public ImageUploadResponse uploadImage(MultipartFile file) { + validateFile(file); + + String originalFilename = file.getOriginalFilename(); + String extension = extractExtension(originalFilename); + String imageKey = "images/" + UUID.randomUUID() + "." + extension; + + try { + PutObjectRequest putRequest = + PutObjectRequest.builder() + .bucket(bucketName) + .key(imageKey) + .contentType(file.getContentType()) + .build(); + + s3Client.putObject( + putRequest, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + + String imageUrl = "/api/v1/images/" + imageKey; + log.info("S3 이미지 업로드 성공: bucket={}, key={}", bucketName, imageKey); + return new ImageUploadResponse(imageUrl, imageKey); + } catch (IOException | S3Exception e) { + log.error( + "S3 이미지 업로드 실패: bucket={}, key={}, error={}", + bucketName, + imageKey, + e.getMessage()); + throw new BusinessException(ErrorCode.FILE_UPLOAD_FAILED); + } + } + + public ImageDownloadResponse getImage(String imageKey) { + if (imageKey == null || imageKey.isBlank() || imageKey.contains("..")) { + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + + try { + GetObjectRequest getRequest = + GetObjectRequest.builder().bucket(bucketName).key(imageKey).build(); + + ResponseInputStream s3Object = s3Client.getObject(getRequest); + byte[] bytes = s3Object.readAllBytes(); + String contentType = s3Object.response().contentType(); + + return new ImageDownloadResponse(bytes, contentType); + } catch (NoSuchKeyException e) { + log.warn("S3 이미지를 찾을 수 없음: bucket={}, key={}", bucketName, imageKey); + throw new BusinessException(ErrorCode.FILE_NOT_FOUND); + } catch (IOException | S3Exception e) { + log.error( + "S3 이미지 조회 실패: bucket={}, key={}, error={}", + bucketName, + imageKey, + e.getMessage()); + throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + private void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new BusinessException(ErrorCode.INVALID_FILE_TYPE); + } + + if (file.getSize() > MAX_FILE_SIZE) { + throw new BusinessException(ErrorCode.FILE_SIZE_EXCEEDED); + } + + String extension = extractExtension(file.getOriginalFilename()); + if (!ALLOWED_EXTENSIONS.contains(extension)) { + throw new BusinessException(ErrorCode.INVALID_FILE_TYPE); + } + + String contentType = file.getContentType(); + if (contentType == null || !ALLOWED_MIME_TYPES.contains(contentType.toLowerCase())) { + throw new BusinessException(ErrorCode.INVALID_FILE_TYPE); + } + } + + private String extractExtension(String filename) { + if (filename == null || !filename.contains(".")) { + throw new BusinessException(ErrorCode.INVALID_FILE_TYPE); + } + return filename.substring(filename.lastIndexOf(".") + 1).toLowerCase(); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java b/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java index 88388c7..d7a46c0 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java +++ b/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java @@ -55,7 +55,11 @@ public AuthenticationManager authenticationManager( @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOrigins(List.of("http://localhost:3000")); + config.setAllowedOrigins( + List.of( + "http://localhost:3000", + "https://snowthing.org", + "https://www.snowthing.org")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); diff --git a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java index 6ea254d..d1a1c6b 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java +++ b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java @@ -32,6 +32,11 @@ public enum ErrorCode { HttpStatus.BAD_REQUEST, "COMMENT_005", "댓글 페이지 크기는 1 이상 50 이하이어야 합니다."), COMMENT_UPDATE_CONFLICT( HttpStatus.CONFLICT, "COMMENT_006", "다른 요청에서 댓글을 먼저 수정했습니다. 최신 댓글을 다시 확인해 주세요."), + INVALID_FILE_TYPE( + HttpStatus.BAD_REQUEST, "FILE_001", "지원하지 않는 파일 형식입니다. JPG, PNG, WEBP 이미지만 업로드 가능합니다."), + FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE_002", "파일 크기는 최대 5MB까지 업로드 가능합니다."), + FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "FILE_003", "존재하지 않는 파일입니다."), + FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE_004", "파일 업로드에 실패했습니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SERVER_001", "서버 내부 오류가 발생했습니다."); private final HttpStatus status; diff --git a/backend/src/main/java/com/ikae/snowthing/global/exception/BusinessException.java b/backend/src/main/java/com/ikae/snowthing/global/exception/BusinessException.java new file mode 100644 index 0000000..4807305 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/global/exception/BusinessException.java @@ -0,0 +1,16 @@ +package com.ikae.snowthing.global.exception; + +import com.ikae.snowthing.global.error.ErrorCode; + +import lombok.Getter; + +@Getter +public class BusinessException extends RuntimeException { + + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java index e114580..637cc79 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java @@ -20,6 +20,12 @@ @RestControllerAdvice public class GlobalExceptionHandler { + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException(BusinessException e) { + return ResponseEntity.status(e.getErrorCode().getStatus()) + .body(ErrorResponse.from(e.getErrorCode())); + } + @ExceptionHandler(CustomAuthException.class) public ResponseEntity handleCustomAuthException(CustomAuthException e) { ErrorCode errorCode = diff --git a/backend/src/main/java/com/ikae/snowthing/global/infra/s3/S3Config.java b/backend/src/main/java/com/ikae/snowthing/global/infra/s3/S3Config.java new file mode 100644 index 0000000..2d8a9f0 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/global/infra/s3/S3Config.java @@ -0,0 +1,24 @@ +package com.ikae.snowthing.global.infra.s3; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Configuration +public class S3Config { + + @Value("${cloud.aws.region.static:ap-northeast-2}") + private String region; + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build(); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index c9cdfb6..e7d74ac 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -45,6 +45,7 @@ spring: jpa: hibernate: ddl-auto: update + show-sql: true properties: hibernate: @@ -53,12 +54,12 @@ spring: --- # ========================================== -# [Profile: docker / prod] Docker Compose 및 운영 컨테이너 환경 +# [Profile: docker] 로컬 Docker Compose 환경 # ========================================== spring: config: activate: - on-profile: docker, prod + on-profile: docker datasource: url: jdbc:mysql://mysql:3306/snowthing?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul @@ -73,3 +74,41 @@ spring: properties: hibernate: dialect: org.hibernate.dialect.MySQLDialect + +--- +# ========================================== +# [Profile: prod] AWS EC2 + RDS 운영 컨테이너 환경 (IAM 무암호 인증) +# ========================================== +spring: + config: + activate: + on-profile: prod + + datasource: + url: ${SNOWTHING_PROD_DB_URL} + driver-class-name: software.amazon.jdbc.Driver + username: ${SNOWTHING_DB_USERNAME:snowthing_app} + password: ${SNOWTHING_DB_PASSWORD:} + hikari: + maximum-pool-size: 10 + minimum-idle: 5 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + data-source-properties: + wrapperPlugins: iam + iamExpiration: 870 + + jpa: + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + +logging: + level: + org.hibernate.SQL: info + org.hibernate.orm.jdbc.bind: info + com.ikae.snowthing: info diff --git a/backend/src/test/java/com/ikae/snowthing/domain/image/ImageServiceTest.java b/backend/src/test/java/com/ikae/snowthing/domain/image/ImageServiceTest.java new file mode 100644 index 0000000..7109f0d --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/image/ImageServiceTest.java @@ -0,0 +1,145 @@ +package com.ikae.snowthing.domain.image; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import com.ikae.snowthing.domain.image.dto.ImageDownloadResponse; +import com.ikae.snowthing.domain.image.dto.ImageUploadResponse; +import com.ikae.snowthing.domain.image.service.ImageService; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.BusinessException; + +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.http.AbortableInputStream; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; + +@ExtendWith(MockitoExtension.class) +class ImageServiceTest { + + @Mock private S3Client s3Client; + + private ImageService imageService; + + @BeforeEach + void setUp() { + imageService = new ImageService(s3Client); + ReflectionTestUtils.setField(imageService, "bucketName", "snowthing-media-00001"); + } + + @Test + @DisplayName("정상 이미지 업로드 시 S3 putObject 호출 후 ImageUploadResponse 반환") + void uploadImage_success() { + MockMultipartFile file = + new MockMultipartFile( + "file", "test.png", "image/png", "test-image-content".getBytes()); + + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenReturn(PutObjectResponse.builder().build()); + + ImageUploadResponse response = imageService.uploadImage(file); + + assertThat(response.imageUrl()).startsWith("/api/v1/images/images/"); + assertThat(response.imageUrl()).endsWith(".png"); + assertThat(response.imageKey()).startsWith("images/"); + assertThat(response.imageKey()).endsWith(".png"); + + verify(s3Client, times(1)).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("5MB 초과 파일 업로드 시 FILE_SIZE_EXCEEDED 예외 발생") + void uploadImage_exceedsMaxSize() { + byte[] largeBytes = new byte[5 * 1024 * 1024 + 1]; + MockMultipartFile file = + new MockMultipartFile("file", "large.png", "image/png", largeBytes); + + assertThatThrownBy(() -> imageService.uploadImage(file)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.FILE_SIZE_EXCEEDED); + + verifyNoInteractions(s3Client); + } + + @Test + @DisplayName("지원하지 않는 확장자 파일 업로드 시 INVALID_FILE_TYPE 예외 발생") + void uploadImage_invalidExtension() { + MockMultipartFile file = + new MockMultipartFile( + "file", "danger.exe", "application/octet-stream", "malicious".getBytes()); + + assertThatThrownBy(() -> imageService.uploadImage(file)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_FILE_TYPE); + + verifyNoInteractions(s3Client); + } + + @Test + @DisplayName("정상 이미지 조회 시 ResponseInputStream에서 바이트 배열과 contentType 반환") + void getImage_success() { + String key = "images/sample.png"; + byte[] expectedBytes = "image-bytes".getBytes(); + GetObjectResponse response = GetObjectResponse.builder().contentType("image/png").build(); + ResponseInputStream responseInputStream = + new ResponseInputStream<>( + response, + AbortableInputStream.create(new ByteArrayInputStream(expectedBytes))); + + when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(responseInputStream); + + ImageDownloadResponse download = imageService.getImage(key); + + assertThat(download.data()).isEqualTo(expectedBytes); + assertThat(download.contentType()).isEqualTo("image/png"); + } + + @Test + @DisplayName("경로 탐색(..) 등 악의적인 key 요청 시 INVALID_INPUT 예외 발생") + void getImage_pathTraversal_throwsException() { + assertThatThrownBy(() -> imageService.getImage("../../../etc/passwd")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + + verifyNoInteractions(s3Client); + } + + @Test + @DisplayName("존재하지 않는 파일 키 조회 시 FILE_NOT_FOUND 예외 발생") + void getImage_notFound_throwsException() { + when(s3Client.getObject(any(GetObjectRequest.class))) + .thenThrow( + NoSuchKeyException.builder() + .message("The specified key does not exist.") + .build()); + + assertThatThrownBy(() -> imageService.getImage("images/nonexistent.png")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.FILE_NOT_FOUND); + } +} diff --git a/compose.prod.yml b/compose.prod.yml new file mode 100644 index 0000000..39dadeb --- /dev/null +++ b/compose.prod.yml @@ -0,0 +1,23 @@ +services: + backend: + image: ${BACKEND_IMAGE_URI:-snowthing-backend:latest} + container_name: snowthing-backend + restart: unless-stopped + ports: + - "127.0.0.1:8080:8080" + environment: + - SPRING_PROFILES_ACTIVE=prod + - SNOWTHING_PROD_DB_URL=${SNOWTHING_PROD_DB_URL:?SNOWTHING_PROD_DB_URL must be set} + - SNOWTHING_DB_USERNAME=${SNOWTHING_DB_USERNAME:-snowthing_app} + - SNOWTHING_DB_PASSWORD=${SNOWTHING_DB_PASSWORD:-} + - TZ=Asia/Seoul + + frontend: + image: ${FRONTEND_IMAGE_URI:-snowthing-frontend:latest} + container_name: snowthing-frontend + restart: unless-stopped + ports: + - "127.0.0.1:3000:3000" + environment: + - NODE_ENV=production + - PORT=3000 diff --git a/database/production/001_initial_schema.sql b/database/production/001_initial_schema.sql new file mode 100644 index 0000000..a472ec2 --- /dev/null +++ b/database/production/001_initial_schema.sql @@ -0,0 +1,149 @@ +-- ==================================================================== +-- Snowthing Production Initial Schema (MySQL 8.0) +-- Version: 001 +-- Description: 운영 RDS 최초 스키마 생성 스크립트 (비파괴 CREATE TABLE IF NOT EXISTS) +-- ==================================================================== + +-- 1. 크루 마스터 테이블 (1인 1크루 소속) +CREATE TABLE IF NOT EXISTS `crew` ( + `crew_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '크루 고유 식별자', + `public_id` VARCHAR(36) NOT NULL UNIQUE COMMENT '외부 노출용 UUID v7', + `name` VARCHAR(100) NOT NULL COMMENT '크루 이름', + `description` TEXT NULL COMMENT '크루 소개', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='크루 마스터'; + +-- 2. 회원 마스터 테이블 +CREATE TABLE IF NOT EXISTS `member` ( + `member_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'DB 내부 조인용 PK', + `public_id` VARCHAR(36) NOT NULL UNIQUE COMMENT '외부 API/URL 노출용 UUID v7', + `email` VARCHAR(100) NOT NULL UNIQUE COMMENT '로그인 이메일 계정', + `password` VARCHAR(255) NULL COMMENT 'BCrypt 암호화 비밀번호 (OAuth2 대비 NULLABLE)', + `nickname` VARCHAR(50) NOT NULL UNIQUE COMMENT '활동 닉네임', + `profile_image_url` VARCHAR(500) NULL COMMENT '프로필 이미지 URL', + `bio` VARCHAR(255) NULL COMMENT '자기소개 한마디', + `departure_region` VARCHAR(100) NULL COMMENT '주 출발/거주 지역', + `crew_id` BIGINT NULL COMMENT '소속 크루 ID', + `crew_role` VARCHAR(20) NULL COMMENT '크루 내 권한 (OWNER, MANAGER, MEMBER)', + `role` VARCHAR(20) NOT NULL DEFAULT 'ROLE_USER' COMMENT '전역 권한 (ROLE_USER, ROLE_ADMIN)', + `status` VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '계정 상태 (ACTIVE, SUSPENDED, WITHDRAWN)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '가입 일시', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + CONSTRAINT `fk_member_crew` FOREIGN KEY (`crew_id`) REFERENCES `crew` (`crew_id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='회원 마스터'; + +-- 3. 리조트 마스터 테이블 (Resort.java: length=100, unique=true) +CREATE TABLE IF NOT EXISTS `resort` ( + `resort_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '리조트 고유 식별자', + `name` VARCHAR(100) NOT NULL UNIQUE COMMENT '스키장 이름', + `region` VARCHAR(50) NOT NULL COMMENT '소재 지역' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='리조트 마스터'; + +-- 4. 회원-리조트 N:M 중계 테이블 +CREATE TABLE IF NOT EXISTS `member_resort` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '단일 대리키 PK', + `member_id` BIGINT NOT NULL COMMENT '회원 ID', + `resort_id` BIGINT NOT NULL COMMENT '리조트 ID', + `is_main` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '주 베이스 여부', + CONSTRAINT `fk_mr_member` FOREIGN KEY (`member_id`) REFERENCES `member` (`member_id`) ON DELETE CASCADE, + CONSTRAINT `fk_mr_resort` FOREIGN KEY (`resort_id`) REFERENCES `resort` (`resort_id`) ON DELETE CASCADE, + CONSTRAINT `uk_member_resort` UNIQUE (`member_id`, `resort_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='회원-리조트 N:M 중계'; + +-- 5. 라이딩 스타일 마스터 테이블 (RidingStyle.java: description, name UNIQUE) +CREATE TABLE IF NOT EXISTS `riding_style` ( + `style_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '스타일 고유 식별자', + `name` VARCHAR(50) NOT NULL UNIQUE COMMENT '라이딩 스타일명', + `description` VARCHAR(255) NULL COMMENT '스타일 상세 설명' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='라이딩 스타일 마스터'; + +-- 6. 회원-라이딩스타일 N:M 중계 테이블 +CREATE TABLE IF NOT EXISTS `member_riding_style` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '단일 대리키 PK', + `member_id` BIGINT NOT NULL COMMENT '회원 ID', + `style_id` BIGINT NOT NULL COMMENT '라이딩 스타일 ID', + CONSTRAINT `fk_mrs_member` FOREIGN KEY (`member_id`) REFERENCES `member` (`member_id`) ON DELETE CASCADE, + CONSTRAINT `fk_mrs_style` FOREIGN KEY (`style_id`) REFERENCES `riding_style` (`style_id`) ON DELETE CASCADE, + CONSTRAINT `uk_member_riding_style` UNIQUE (`member_id`, `style_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='회원-라이딩스타일 N:M 중계'; + +-- 7. 게시판 카테고리 테이블 +CREATE TABLE IF NOT EXISTS `post_category` ( + `category_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '카테고리 식별자', + `name` VARCHAR(50) NOT NULL COMMENT '카테고리명', + `code` VARCHAR(50) NOT NULL UNIQUE COMMENT '카테고리 고유 코드' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='게시판 카테고리'; + +-- 8. 게시글 테이블 +CREATE TABLE IF NOT EXISTS `post` ( + `post_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '게시글 고유 식별자', + `public_id` VARCHAR(36) NOT NULL UNIQUE COMMENT '외부 API/URL 노출용 UUID v7', + `member_id` BIGINT NULL COMMENT '작성자 회원 ID (비회원 작성 시 NULL)', + `category_id` BIGINT NOT NULL COMMENT '카테고리 ID', + `title` VARCHAR(200) NOT NULL COMMENT '게시글 제목', + `content` LONGTEXT NOT NULL COMMENT '게시글 본문', + `writer_ip` VARCHAR(45) NOT NULL COMMENT '작성자 IP 주소', + `is_anonymous` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '익명 작성 여부', + `anonymous_password` VARCHAR(255) NULL COMMENT '비회원 익명 수정/삭제용 비밀번호 (BCrypt)', + `view_count` INT NOT NULL DEFAULT 0 COMMENT '조회수', + `comment_count` INT NOT NULL DEFAULT 0 COMMENT '댓글 수 역정규화', + `like_count` INT NOT NULL DEFAULT 0 COMMENT '추천 수 역정규화', + `dislike_count` INT NOT NULL DEFAULT 0 COMMENT '비추천 수 역정규화', + `has_image` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '이미지 첨부 여부 역정규화 (목록 뱃지 아이콘 렌더링용)', + `status` VARCHAR(20) NOT NULL DEFAULT 'NORMAL' COMMENT '게시글 상태 (NORMAL, DELETED, BLOCKED, HIDDEN, DRAFT)', + `is_deleted` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Soft Delete 여부', + `deleted_at` DATETIME NULL COMMENT '삭제 일시', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '작성 일시', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + CONSTRAINT `fk_post_member` FOREIGN KEY (`member_id`) REFERENCES `member` (`member_id`) ON DELETE SET NULL, + CONSTRAINT `fk_post_category` FOREIGN KEY (`category_id`) REFERENCES `post_category` (`category_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='게시글'; + +-- 9. 게시글 첨부 이미지 테이블 (PostImage.java: BaseTimeEntity 상속으로 updated_at 포함) +CREATE TABLE IF NOT EXISTS `post_image` ( + `image_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '이미지 고유 식별자', + `post_id` BIGINT NOT NULL COMMENT '게시글 ID', + `image_url` VARCHAR(500) NOT NULL COMMENT '이미지 파일 URL', + `sort_order` INT NOT NULL DEFAULT 1 COMMENT '표시 순서', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '등록 일시', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + CONSTRAINT `fk_pi_post` FOREIGN KEY (`post_id`) REFERENCES `post` (`post_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='게시글 첨부 이미지'; + +-- 10. 게시글 추천/비추천 중계 테이블 +CREATE TABLE IF NOT EXISTS `post_reaction` ( + `reaction_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '반응 식별자', + `post_id` BIGINT NOT NULL COMMENT '게시글 ID', + `member_id` BIGINT NULL COMMENT '회원 ID (비회원 투표 시 NULL)', + `writer_ip` VARCHAR(45) NULL COMMENT '투표자 IP 주소', + `anonymous_voter_id` VARCHAR(36) NULL COMMENT '비회원 익명 투표자 식별 쿠키 ID', + `type` VARCHAR(10) NOT NULL COMMENT '반응 종류 (LIKE, DISLIKE)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '투표 일시', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + CONSTRAINT `fk_pr_post` FOREIGN KEY (`post_id`) REFERENCES `post` (`post_id`) ON DELETE CASCADE, + CONSTRAINT `fk_pr_member` FOREIGN KEY (`member_id`) REFERENCES `member` (`member_id`) ON DELETE CASCADE, + CONSTRAINT `uk_post_member_type` UNIQUE (`post_id`, `member_id`, `type`), + CONSTRAINT `uk_post_anon_voter_type` UNIQUE (`post_id`, `anonymous_voter_id`, `type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='게시글 추천/비추천'; + +-- 11. 댓글 및 계층형 대댓글 테이블 (Comment.java: @Version version 컬럼 포함) +CREATE TABLE IF NOT EXISTS `comment` ( + `comment_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '댓글 식별자', + `version` BIGINT NOT NULL DEFAULT 0 COMMENT '낙관적 락 동시성 제어용 버전', + `post_id` BIGINT NOT NULL COMMENT '게시글 ID', + `member_id` BIGINT NULL COMMENT '작성자 회원 ID (비회원 작성 시 NULL)', + `parent_id` BIGINT NULL COMMENT '부모 댓글 ID (대댓글 구현용)', + `content` VARCHAR(1000) NOT NULL COMMENT '댓글 내용', + `writer_ip` VARCHAR(45) NOT NULL COMMENT '작성자 IP 주소', + `is_anonymous` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '익명 작성 여부', + `anonymous_password` VARCHAR(255) NULL COMMENT '비회원 익명 수정/삭제용 비밀번호 (BCrypt)', + `is_deleted` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Soft Delete 여부', + `deleted_at` DATETIME NULL COMMENT '삭제 일시', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '작성 일시', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + INDEX `idx_comment_post_parent_id` (`post_id`, `parent_id`, `comment_id`), + INDEX `idx_comment_parent_deleted_id` (`parent_id`, `is_deleted`, `comment_id`), + CONSTRAINT `fk_comment_post` FOREIGN KEY (`post_id`) REFERENCES `post` (`post_id`) ON DELETE CASCADE, + CONSTRAINT `fk_comment_member` FOREIGN KEY (`member_id`) REFERENCES `member` (`member_id`) ON DELETE SET NULL, + CONSTRAINT `fk_comment_parent` FOREIGN KEY (`parent_id`) REFERENCES `comment` (`comment_id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='댓글 및 계층형 대댓글'; diff --git a/database/production/002_reference_data.sql b/database/production/002_reference_data.sql new file mode 100644 index 0000000..85da1eb --- /dev/null +++ b/database/production/002_reference_data.sql @@ -0,0 +1,31 @@ +-- ==================================================================== +-- Snowthing Production Reference Data (MySQL 8.0) +-- Version: 002 +-- Description: 운영 환경 필수 마스터 기준데이터 삽입 스크립트 (멱등성 보장 INSERT IGNORE) +-- ==================================================================== + +-- 1. 리조트 마스터 기준데이터 (6개) +INSERT IGNORE INTO `resort` (`name`, `region`) VALUES +('휘닉스파크', '강원 평창'), +('하이원리조트', '강원 정선'), +('모나용평', '강원 평창'), +('비발디파크', '강원 홍천'), +('웰리힐리파크', '강원 횡성'), +('지산리조트', '경기 이천'); + +-- 2. 라이딩 스타일 마스터 기준데이터 (6개) +INSERT IGNORE INTO `riding_style` (`name`, `description`) VALUES +('올라운드', '슬로프, 트릭, 파크 등을 가리지 않고 다양하게 즐기는 스타일'), +('라이딩 / 카빙', '슬로프 고속 라이딩 및 칼날 카빙'), +('그라운드 트릭', '평지 버터링, 알리, 스핀 트릭'), +('파크 / 기물 / 파이프', '킥커 점프, 레일, 하프파이프'), +('입문 / 초보', '기초 자세 및 B턴, J턴 습득 중'), +('관광 / 크루징', '풍경 감상 및 여유로운 라이딩'); + +-- 3. 게시판 카테고리 마스터 기준데이터 (5개) +INSERT IGNORE INTO `post_category` (`name`, `code`) VALUES +('자유게시판', 'FREE'), +('익명게시판', 'ANONYMOUS'), +('질문게시판', 'QNA'), +('장비VS', 'GEAR_VS'), +('맛집게시판', 'FOOD'); diff --git "a/docs/conception/sprint06/01-\353\260\260\355\217\254-PR-URL-SHA-digest-\354\236\224\354\227\254\354\235\264\354\212\210.md" "b/docs/conception/sprint06/01-\353\260\260\355\217\254-PR-URL-SHA-digest-\354\236\224\354\227\254\354\235\264\354\212\210.md" new file mode 100644 index 0000000..fea30f3 --- /dev/null +++ "b/docs/conception/sprint06/01-\353\260\260\355\217\254-PR-URL-SHA-digest-\354\236\224\354\227\254\354\235\264\354\212\210.md" @@ -0,0 +1,49 @@ +# Sprint 06 배포 결과 + +## 제출 정보 + +- 배포 브랜치: `deploy/aws-ec2` +- 운영 URL: `https://snowthing.org` +- ECR 전환 커밋: `eaf7acc669f1c9ae0f82dee2ca07a65bfdfb4fbb` +- 로그인 UI 및 수동 롤백 추가 커밋: `bf94203015a6edf6d0b0a977af5e4afb2c9c5710` +- 배포 PR: 아직 만들지 않았습니다. 현재 결과를 정리한 뒤 `deploy/aws-ec2`에서 `main`으로 PR을 열어야 합니다. + +## 실제 배포 이미지 + +| 서비스 | 커밋 | ECR 태그 | 배포한 digest | +|---|---|---|---| +| 프런트 초기 버전 | `eaf7acc...` | `release-eaf7acc669f1c9ae0f82dee2ca07a65bfdfb4fbb` | `sha256:acba5de24d57eee6fa56da4845db6d59fc1b0a43e7b8d9867dd4fd1586b6798d` | +| 백엔드 | `eaf7acc...` | `release-eaf7acc669f1c9ae0f82dee2ca07a65bfdfb4fbb` | `sha256:f47b877a6aac0e3eb7b973bba78999a765e3415c7f7fda6c15a82834fca26d9c` | +| 프런트 새 로그인 UI | `bf94203...` | `release-bf94203015a6edf6d0b0a977af5e4afb2c9c5710` | `sha256:91f7b9d8027704d895ff697e37fea8329514cf684d619242f77e229a59946ffb` | + +태그는 사람이 커밋을 찾을 때 사용하고, EC2에는 변경할 수 없는 digest 주소를 전달했습니다. 같은 태그가 가리키는 대상을 나중에 바꾸는 방식으로는 배포하지 않았습니다. + +## Actions 실행 결과 + +- [초기 프런트 배포](https://github.com/devikae/snowthing/actions/runs/35076355024): CI 29초, 빌드·push·배포 1분 59초 +- [초기 백엔드 배포](https://github.com/devikae/snowthing/actions/runs/35076355176): CI 2분 55초, 빌드·push·배포 2분 6초 +- [새 로그인 UI 배포](https://github.com/devikae/snowthing/actions/runs/35079283676): CI 29초, 빌드·push·배포 1분 52초 +- [이전 프런트 digest 롤백](https://github.com/devikae/snowthing/actions/runs/35079786140): 전체 27초, digest 조회와 SSM 배포 19초 +- [새 프런트 digest 재배포](https://github.com/devikae/snowthing/actions/runs/35081931883): 전체 31초, digest 조회와 SSM 배포 21초 + +## 확인한 결과 + +- 새 로그인 UI 배포 후 `/login`에서 새 문구를 확인했습니다. +- 이전 digest로 롤백한 뒤 `/login`이 이전 UI로 바뀌었습니다. +- 새 digest를 다시 배포한 뒤 새 UI가 복구됐습니다. +- 롤백과 재배포 뒤 `GET /api/v1/posts`, `GET /api/v1/master/resorts`가 모두 `200 OK`를 반환했습니다. +- 프런트와 백엔드 컨테이너는 EC2에서 빌드하지 않았습니다. EC2는 ECR 이미지를 pull해 실행했습니다. + +## 잔여 이슈 + +1. `deploy/aws-ec2` 변경을 `main`에 병합하기 전이라 새 수동 관리 워크플로를 기본 브랜치에서 직접 실행할 수 없습니다. PR 검토와 병합이 필요합니다. +2. RDS 자동 백업 설정과 운영 연결은 완료됐지만, 백업을 별도 DB로 복원해 읽기 전용 검증 앱으로 확인하는 절차는 아직 남았습니다. 새 DB 비용과 복원 사양 승인이 필요합니다. +3. 최초 프런트와 백엔드 배포 사이에 API가 잠시 `502 Bad Gateway`를 반환했습니다. 당시 로그가 충분하지 않아 원인을 단정하지 않았습니다. 이후 배포 실패 시 Docker 상태·제한된 inspect 결과·컨테이너 로그·Nginx error log를 같은 UTC 시각으로 저장하도록 보강했습니다. + +## 추가 완료 항목 + +- 공식 액션을 Node.js 24 기반 최신 주요 버전으로 갱신한 뒤 프런트·백엔드 CI와 배포를 다시 통과했습니다. +- 백엔드 `stable-3ecdc50...` → `sha256:dec23cf6574e4fecddfb32946ce88a924afbc3561f31bb6069e55d0b714c9016` +- 프런트 `stable-3ecdc50...` → `sha256:3bdfa20b2d63f9fba13296f3bd3daba0662cec87f3e2e112df0396a38ae7b2aa` +- stable 지정 후 로그인·게시글·리조트 API `200 OK`를 확인했습니다. +- [백엔드 stable 지정](https://github.com/devikae/snowthing/actions/runs/35087127962), [프런트 stable 지정](https://github.com/devikae/snowthing/actions/runs/35087130593) diff --git "a/docs/conception/sprint06/02-\352\265\254\354\241\260-\353\271\204\354\232\251-\354\204\244\354\240\225-\354\232\264\354\230\201\354\240\210\354\260\250.md" "b/docs/conception/sprint06/02-\352\265\254\354\241\260-\353\271\204\354\232\251-\354\204\244\354\240\225-\354\232\264\354\230\201\354\240\210\354\260\250.md" new file mode 100644 index 0000000..694f622 --- /dev/null +++ "b/docs/conception/sprint06/02-\352\265\254\354\241\260-\353\271\204\354\232\251-\354\204\244\354\240\225-\354\232\264\354\230\201\354\240\210\354\260\250.md" @@ -0,0 +1,87 @@ +# Sprint 06 구조와 운영 절차 + +## 현재 구조 + +```text +브라우저 + → Cloudflare DNS·프록시·HTTPS + → EC2 보안 그룹 + → 호스트 Nginx + ├─ 화면 요청 → 127.0.0.1:3000 프런트 컨테이너 + └─ /api 요청 → 127.0.0.1:8080 백엔드 컨테이너 + ├─ RDS MySQL + └─ EC2 IAM Role → 비공개 S3 + +GitHub Actions + → OIDC로 AWS 배포 역할 수임 + → 프런트·백엔드 이미지 빌드 + → ECR에 candidate- push + → digest 조회 + → SSM Run Command + → EC2가 digest 이미지 pull·실행 +``` + +이전에는 EC2가 Git 소스를 받은 뒤 `docker compose up --build`로 직접 빌드했습니다. 지금은 GitHub Actions가 한 번 빌드한 이미지를 ECR에 보관하고, EC2는 그 결과물만 실행합니다. 서버 사양과 캐시 상태에 따라 빌드 결과가 달라지는 문제를 줄였고, 실행했던 바이너리와 되돌릴 바이너리를 digest로 구분할 수 있게 됐습니다. + +## 주요 설정 키 + +### GitHub Actions Variables + +- `AWS_DEPLOY_ROLE_ARN`: OIDC로 수임할 GitHub 배포 역할 ARN +- `AWS_REGION`: `ap-northeast-2` +- `EC2_INSTANCE_ID`: SSM 명령을 받을 운영 EC2 ID +- `ECR_BACKEND_REPOSITORY`: `snowthing/backend` +- `ECR_FRONTEND_REPOSITORY`: `snowthing/frontend` +- `NEXT_PUBLIC_API_BASE_URL`: `https://snowthing.org` + +비밀번호가 아닌 저장소 이름과 리전은 Variables에 둡니다. DB 비밀번호처럼 노출되면 안 되는 값은 GitHub 변수나 이미지에 넣지 않고 EC2의 `/etc/snowthing/prod.env`에서 관리합니다. + +### IAM 역할 + +- GitHub 역할: 두 ECR 저장소에 대한 push·조회·태그 삭제, 대상 EC2에 대한 SSM 명령 전송과 결과 조회 +- EC2 역할: 두 ECR 저장소 pull, 필요한 S3 경로 접근, SSM Agent 통신 + +`ecr:GetAuthorizationToken`은 리소스 ARN으로 제한할 수 없어 `Resource: "*"`를 사용합니다. 이미지 작업 권한은 `snowthing/backend`, `snowthing/frontend` ARN으로 제한합니다. + +## ECR 태그와 보관 정책 + +- `candidate-`: 배포 전 후보. 7일 후 만료 +- `release-`: 배포와 헬스체크가 끝난 버전. 최근 3개 보관 +- `stable-`: 장기 복구 기준. 최근 1개 보관 +- 태그 없는 이미지: 1일 후 만료 + +candidate와 release 태그를 한 이미지에 계속 같이 두면 candidate 7일 규칙이 release 이미지까지 만료시킬 수 있습니다. 그래서 배포 성공 후 release 태그를 만든 다음 candidate 태그를 제거합니다. stable로 옮길 때도 같은 digest에 남은 release 태그가 없는지 확인해야 합니다. + +## 비용 + +ECR private 저장소는 저장 용량 기준으로 과금됩니다. 서울 리전의 정확한 금액은 결제 시점의 AWS 요금표로 다시 확인해야 하지만, 작업 당시 계산에는 약 USD 0.10/GB-월을 사용했습니다. 5GB면 약 USD 0.50/월, 10GB면 약 USD 1/월 수준이며 세금과 환율은 별도입니다. 같은 리전의 ECR에서 EC2로 이미지를 받는 데이터 전송은 별도 인터넷 다운로드보다 유리하지만, 스캔 기능이나 리전 간 전송을 추가하면 비용이 달라질 수 있습니다. + +## 정상 배포 절차 + +1. 백엔드는 Spotless와 테스트, 프런트는 `npm run build`를 통과해야 합니다. +2. Actions가 `candidate-<전체 커밋 SHA>` 이미지를 만듭니다. +3. ECR push 후 digest를 조회합니다. +4. SSM으로 `저장소@sha256:...` 주소를 EC2에 전달합니다. +5. EC2가 ECR에 로그인하고 해당 digest를 pull합니다. +6. Compose가 대상 서비스 하나만 `--no-build`로 교체합니다. +7. 프런트는 3000번, 백엔드는 8080번 로컬 주소로 헬스체크합니다. +8. 성공하면 release 태그를 만들고 candidate 태그를 지웁니다. +9. 실패하면 배포 전에 실행 중이던 이미지 주소로 컨테이너를 다시 올립니다. + +## 수동 롤백 절차 + +1. ECR에서 되돌릴 `release-` 또는 `stable-`를 고릅니다. +2. GitHub Actions의 `Deploy Frontend` 수동 실행에서 `rollback_tag`를 입력합니다. +3. Actions가 태그를 digest로 바꾼 뒤 SSM으로 전달합니다. +4. EC2가 선택한 digest로 컨테이너를 교체하고 헬스체크합니다. +5. 외부 HTTPS에서 화면과 API를 확인합니다. +6. DB 스키마 변경이 포함된 버전이라면 이미지부터 바꾸지 말고 이전 앱과 현재 스키마의 호환성을 먼저 확인합니다. + +## 백업·복구와 자원 정리 + +- 이미지 롤백은 애플리케이션 실행 파일만 되돌립니다. RDS 데이터는 되돌리지 않습니다. +- DB 복구는 운영 RDS를 덮어쓰지 않고 새 DB로 복원해야 합니다. +- 복원 검증 앱은 운영 DB와 S3에 쓰기 권한을 가지면 안 됩니다. +- 검증이 끝난 임시 DB와 사용하지 않는 스냅샷은 승인된 보존 기간에 따라 정리합니다. +- EC2, EBS, 공인 IPv4, RDS, 백업, S3, ECR 비용을 함께 확인합니다. Budgets 알림은 자동 결제 차단 기능이 아닙니다. + diff --git "a/docs/conception/sprint06/03-\353\260\260\355\217\254-\353\263\265\352\265\254-\354\246\235\352\261\260\355\221\234.md" "b/docs/conception/sprint06/03-\353\260\260\355\217\254-\353\263\265\352\265\254-\354\246\235\352\261\260\355\221\234.md" new file mode 100644 index 0000000..5583bbb --- /dev/null +++ "b/docs/conception/sprint06/03-\353\260\260\355\217\254-\353\263\265\352\265\254-\354\246\235\352\261\260\355\221\234.md" @@ -0,0 +1,38 @@ +# Sprint 06 배포·복구 증거표 + +| 구분 | 환경·시각 | 기대 결과 | 실제 결과 | 증거 | +|---|---|---|---|---| +| 프런트 최초 ECR 배포 | GitHub Actions·EC2, 2026-09-16 | Actions가 이미지를 만들고 EC2는 digest를 실행 | 성공. CI 29초, 빌드·push·배포 1분 59초 | [실행 35076355024](https://github.com/devikae/snowthing/actions/runs/35076355024), digest `acba5de2...` | +| 백엔드 최초 ECR 배포 | GitHub Actions·EC2, 2026-09-16 | 테스트 통과 후 RDS 연결이 가능한 백엔드 실행 | 성공. CI 2분 55초, 빌드·push·배포 2분 6초 | [실행 35076355176](https://github.com/devikae/snowthing/actions/runs/35076355176), digest `f47b877a...` | +| 배포 중 API 장애 확인 | 외부 HTTPS, 2026-09-16 18:01 KST 전후 | 기존 API가 계속 응답 | `/posts`는 200, 게시글·리조트 API는 502 | Cloudflare 응답과 Actions 진행 시각. 백엔드 배포 후 200 회복 | +| 새 로그인 UI 배포 | GitHub Actions·EC2, 2026-09-16 | 새 release 생성 후 로그인 화면 변경 | 성공. CI 29초, 빌드·push·배포 1분 52초 | [실행 35079283676](https://github.com/devikae/snowthing/actions/runs/35079283676), digest `91f7b9d8...` | +| 이전 버전 롤백 | 외부 HTTPS·EC2, 2026-09-16 18:30 KST | 이전 digest 실행, 이전 UI 표시, API 유지 | 성공. 전체 27초, SSM 단계 19초. 이전 로그인 UI 확인, 게시글 API 200 | [실행 35079786140](https://github.com/devikae/snowthing/actions/runs/35079786140) | +| 최신 버전 재배포 | 외부 HTTPS·EC2, 2026-09-16 18:53 KST | 새 digest 재실행, 새 UI 복구, API 유지 | 성공. 전체 31초, SSM 단계 21초. 새 로그인 UI와 게시글·리조트 API 200 | [실행 35081931883](https://github.com/devikae/snowthing/actions/runs/35081931883) | +| 공식 Actions 런타임 갱신 | GitHub Actions, 2026-09-16 | Node.js 20 경고 제거 후 CI·배포 유지 | 프런트·백엔드 CI와 배포 성공 | [백엔드 35086463921](https://github.com/devikae/snowthing/actions/runs/35086463921), [프런트 35086463890](https://github.com/devikae/snowthing/actions/runs/35086463890) | +| stable 이미지 지정 | ECR, 2026-09-16 | 프런트·백엔드 장기 복구 이미지 1개씩 보관 | 두 서비스 모두 `stable-3ecdc50...` 지정 성공, 외부 API 200 | [백엔드 35087127962](https://github.com/devikae/snowthing/actions/runs/35087127962), [프런트 35087130593](https://github.com/devikae/snowthing/actions/runs/35087130593) | + +## 장애 분석에 사용한 로그 + +게시글을 불러오지 못했을 때 먼저 외부 요청을 나눠 확인했습니다. `/posts`가 200인데 `/api/v1/posts`와 `/api/v1/master/resorts`가 모두 502였으므로 프런트 전체 장애는 아니었습니다. Cloudflare가 반환한 502는 애플리케이션이 만든 JSON 500 응답과 다릅니다. 이 결과로 Nginx 뒤의 백엔드 연결 구간을 우선 확인했습니다. + +그다음 Actions에서 SSM 명령 결과를 확인했습니다. 로그에는 ECR digest pull, 컨테이너 교체, `HTTP 000` 대기, 최종 `HTTP 200`이 순서대로 남았습니다. 백엔드 배포 뒤 외부 게시글·리조트 API가 200으로 돌아왔으므로 복구 지점을 백엔드 컨테이너 기동으로 좁힐 수 있었습니다. 다만 장애 당시의 `docker ps`, 백엔드 로그, Nginx error log를 동시에 보존하지 못했기 때문에 기존 백엔드가 왜 연결 불가 상태였는지는 단정하지 않았습니다. + +## 질문 3개 답변 + +### 1. 실패한 지점을 어떤 로그로 찾았나요? + +한 로그만 보지 않고 요청 경로를 앞에서부터 나눴습니다. 외부 HTTPS에서 화면과 API 상태코드를 각각 확인했고, GitHub Actions에서 CI·ECR push·SSM 단계를 구분했으며, SSM 출력에서 Docker pull·컨테이너 교체·로컬 헬스체크 결과를 확인했습니다. 이번 502는 `/posts`가 200이고 두 API가 함께 502였기 때문에 프런트나 특정 SQL보다 Nginx와 백엔드 사이를 먼저 의심했습니다. 백엔드 배포 로그의 로컬 헬스체크가 200으로 바뀐 직후 외부 API도 200으로 회복했습니다. + +다음 장애부터는 같은 시각의 `docker ps`, `docker inspect`, `docker logs`, Nginx error log와 RDS 연결 오류를 함께 저장해야 원인을 확정할 수 있습니다. 이번 기록은 확인한 사실과 추정을 구분했습니다. + +### 2. 구현한 방식과 대안 하나를 비교하면 어떤 대가가 있나요? + +구현한 방식은 GitHub Actions에서 이미지를 한 번 빌드해 ECR에 저장하고, EC2가 digest로 그 이미지를 실행하는 방식입니다. 대안은 이전처럼 EC2가 Git 소스를 받아 직접 빌드하는 방식입니다. + +ECR 방식은 배포와 롤백에서 같은 결과물을 다시 사용할 수 있고, EC2가 빌드를 하지 않아도 됩니다. 반면 ECR 저장 비용, 이미지 수명 주기 관리, push/pull IAM 권한, 태그 승격 규칙이 추가됩니다. EC2 직접 빌드는 구성이 단순하지만 서버의 디스크·CPU·캐시 상태에 영향을 받고, 과거 커밋을 다시 빌드한 결과가 당시 실행한 이미지와 같다고 보장하기 어렵습니다. 이번에는 ECR 비용을 줄이기 위해 candidate 7일, release 최근 3개, stable 최근 1개 정책을 두고 권한을 두 저장소로 제한했습니다. + +### 3. DB 스키마가 바뀌면 이미지 롤백 전에 무엇을 확인하나요? + +이전 애플리케이션이 현재 DB 스키마를 읽고 쓸 수 있는지 먼저 확인합니다. 새 버전이 컬럼을 삭제하거나 이름을 바꿨다면 이전 이미지가 시작되지 않거나 잘못된 데이터를 저장할 수 있습니다. 이런 상태에서는 이미지 digest만 이전 것으로 바꾸면 안 됩니다. + +스키마 변경은 먼저 컬럼을 추가하고 양쪽 버전이 함께 동작하게 만든 뒤, 데이터 이관과 코드 전환이 끝난 후 오래된 컬럼을 제거하는 식으로 나누는 편이 안전합니다. 롤백 전에는 마이그레이션 버전, 이전 엔티티와 쿼리의 호환성, 데이터 변환의 역변환 가능 여부, 백업 복원 절차를 확인합니다. 호환되지 않으면 자동 롤백을 멈추고 새 DB로 백업을 복원해 검증한 뒤 복구 방향을 정해야 합니다. diff --git a/docs/project/work.md b/docs/project/work.md index 7356b37..b70d6c9 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,112 @@ +- **ECR digest 기반 배포·복구 전환 (2026-09-16)**: + - 상태: 프런트·백엔드 실제 배포와 프런트 이전 버전 롤백·재배포 검증 완료 + - ECR 비공개 저장소 `snowthing/backend`, `snowthing/frontend`와 수명 주기 정책을 준비했습니다. + - GitHub OIDC 역할은 두 저장소에 한정된 push·조회·태그 삭제 권한을, EC2 역할은 두 저장소에 한정된 pull 권한을 사용합니다. + - GitHub Actions가 `candidate-` 이미지를 빌드·push하고 ECR digest를 조회한 뒤, EC2가 `저장소@sha256:...` 주소로 pull·실행하도록 변경했습니다. + - EC2의 소스 빌드를 제거하고 Compose가 외부 이미지 주소를 받도록 변경했습니다. 배포 실패 시 직전에 실행 중이던 이미지로 자동 복구합니다. + - 헬스체크 성공 후 `release-` 태그로 승격하고 candidate 태그를 제거하여 수명 주기 규칙이 release 이미지를 함께 삭제하지 않도록 했습니다. + - 최초 실제 배포 커밋은 `eaf7acc669f1c9ae0f82dee2ca07a65bfdfb4fbb`입니다. 프런트 digest는 `sha256:acba5de24d57eee6fa56da4845db6d59fc1b0a43e7b8d9867dd4fd1586b6798d`, 백엔드 digest는 `sha256:f47b877a6aac0e3eb7b973bba78999a765e3415c7f7fda6c15a82834fca26d9c`입니다. + - 최초 프런트는 CI 29초, 빌드·push·배포 1분 59초가 걸렸습니다. 백엔드는 CI 2분 55초, 빌드·push·배포 2분 6초가 걸렸습니다. + - 배포 도중 외부 API에서 `502 Bad Gateway`를 확인했습니다. 당시 `/posts`는 200이었지만 `/api/v1/posts`와 `/api/v1/master/resorts`는 502였으므로 DB 응답 오류가 아니라 Nginx가 백엔드 8080에 연결하지 못한 상태로 판단했습니다. 백엔드 배포 후 두 API 모두 200으로 회복했습니다. + - 새 로그인 UI 커밋 `bf94203015a6edf6d0b0a977af5e4afb2c9c5710`의 프런트 digest는 `sha256:91f7b9d8027704d895ff697e37fea8329514cf684d619242f77e229a59946ffb`이며, CI 29초와 빌드·push·배포 1분 52초가 걸렸습니다. + - 이전 `release-eaf7acc...` digest로 수동 롤백했습니다. Actions 전체 27초, digest 조회·SSM 배포 19초가 걸렸고, 외부 로그인 화면이 이전 UI로 바뀌면서 게시글 API는 200을 유지했습니다. 실행: https://github.com/devikae/snowthing/actions/runs/35079786140 + - 새 `release-bf94203...` digest 재배포는 Actions 전체 31초, digest 조회·SSM 배포 21초가 걸렸습니다. 외부 로그인 화면이 새 UI로 돌아왔고 게시글·리조트 API 모두 200을 확인했습니다. 실행: https://github.com/devikae/snowthing/actions/runs/35081931883 + - 관련 실행: 최초 프런트 https://github.com/devikae/snowthing/actions/runs/35076355024, 최초 백엔드 https://github.com/devikae/snowthing/actions/runs/35076355176, 새 프런트 https://github.com/devikae/snowthing/actions/runs/35079283676 + - 공식 액션을 `checkout@v7`, `setup-node@v7`, `setup-java@v6`, `setup-gradle@v6`, `configure-aws-credentials@v6`으로 갱신했습니다. 갱신 후 프런트·백엔드 CI와 digest 배포가 모두 성공했고 기존 Node.js 20 사용 중단 경고가 사라졌습니다. + - 배포 실패 시 `/var/log/snowthing-deploy/`에 Docker 상태, 비밀 환경변수를 제외한 inspect 결과, 컨테이너 로그, Nginx error log를 같은 UTC 시각으로 저장하도록 보강했습니다. + - `stable-3ecdc50b305aece0f40697216ea0b62eefc02c97`을 프런트와 백엔드에 지정했습니다. 백엔드 stable digest는 `sha256:dec23cf6574e4fecddfb32946ce88a924afbc3561f31bb6069e55d0b714c9016`, 프런트 stable digest는 `sha256:3bdfa20b2d63f9fba13296f3bd3daba0662cec87f3e2e112df0396a38ae7b2aa`입니다. + - stable 지정 후 로그인·게시글·리조트 API가 모두 200을 반환했습니다. 실행: 백엔드 https://github.com/devikae/snowthing/actions/runs/35087127962, 프런트 https://github.com/devikae/snowthing/actions/runs/35087130593 + - 남은 항목: 비용 승인을 받은 뒤 RDS 백업을 별도 DB로 복원하고 읽기 전용 검증 앱에서 조회하는 절차가 남았습니다. + +- **GitHub Actions 배포 인증을 OIDC + SSM으로 전환 (2026-09-13)**: + - 상태: 진행 중 + - EC2 인스턴스 역할에 `AmazonSSMManagedInstanceCore`를 연결하고 SSM Agent의 제어 채널 연결을 확인했습니다. + - GitHub OIDC 공급자와 배포 전용 IAM 역할을 만들고, `main`과 `deploy/aws-ec2` 브랜치만 역할을 맡을 수 있도록 신뢰 정책을 제한했습니다. + - Repository variables `AWS_DEPLOY_ROLE_ARN`, `AWS_REGION`, `EC2_INSTANCE_ID`를 등록했습니다. + - 백엔드와 프론트엔드 워크플로에서 PEM 기반 SSH 배포를 제거하고, OIDC 임시 자격증명으로 SSM Run Command를 호출하도록 변경했습니다. + - SSM 명령은 최대 15분 동안 완료 상태를 확인하고, 원격 명령의 최종 상태가 `Success`일 때만 Actions 작업을 성공 처리합니다. + - 검증 완료: 백엔드 CI와 SSM 배포 성공([Actions 실행](https://github.com/devikae/snowthing/actions/runs/34757010101)), 프론트엔드 CI와 SSM 배포 성공([Actions 실행](https://github.com/devikae/snowthing/actions/runs/34757130034)). + - 기존 SSH 배포용 `EC2_HOST`, `EC2_USER`, `EC2_SSH_KEY`와 중복 등록된 `AWS_DEPLOY_ROLE_ARN`, `AWS_REGION`, `EC2_INSTANCE_ID` Secrets를 삭제하고 Repository variables만 유지했습니다. + - 남은 작업: UI 디자인 커밋을 이 브랜치에 반영하고 새 SSM 배포로 공개 화면을 검증합니다. + - 운영 CSRF 확인 중 API 주소를 `https://snowthing.org`로 교체했으며, 백엔드 CORS가 localhost만 허용해 `403 Invalid CORS request`를 반환하는 문제를 확인했습니다. 운영 apex/www HTTPS origin을 허용하도록 수정합니다. + - 진단 결과 GitHub OIDC의 실제 `sub`에는 저장소·소유자 식별자가 포함되어 기존 조건과 불일치했습니다. 신뢰 정책을 실제 클레임에 맞춰 수정한 뒤 역할 수임이 성공했습니다. 진단 단계는 확인 후 제거했습니다. + - OIDC 및 SSM 명령 전송은 성공했습니다. root로 실행되는 SSM에서 Git 안전 디렉터리를 전역 설정하려 했으나 `$HOME`이 없어 실패했으므로, 전역 설정 없이 각 Git 명령에 `-c safe.directory=/home/ubuntu/snowthing`을 전달하도록 보완했습니다. + +- **AWS EC2 + RDS + S3 운영 배포 6단계 모노레포 선별적 배포(Selective CI/CD) 파이프라인 분리 구축 (2026-09-11)**: + 1. **작업명**: `.github/workflows/deploy.yml` 단일 워크플로를 `deploy-backend.yml`과 `deploy-frontend.yml`로 분리하여 경로 기반 선별적 배포 및 동시성 큐잉 적용 + 2. **현재 상태**: 완료 (워크플로 파일 분리 생성 및 커밋/푸시 완료) + 3. **완료된 항목**: + - 기존 단일 `.github/workflows/deploy.yml` 제거. + - `.github/workflows/deploy-backend.yml` 신설: + * `backend/**`, `database/**`, `compose.prod.yml` 변경 시에만 트리거. + * 백엔드 CI(Spotless + MySQL 8.0 테스트) 통과 후 EC2에서 `docker compose up -d --build backend` 단독 증분 빌드 및 API 헬스체크(200 OK) 수행. + - `.github/workflows/deploy-frontend.yml` 신설: + * `frontend/**`, `compose.prod.yml` 변경 시에만 트리거. + * 프론트엔드 CI(Next.js 16 프로덕션 빌드) 통과 후 EC2에서 `docker compose up -d --build frontend` 단독 증분 빌드 및 웹 헬스체크(200/304 OK) 수행. + - 두 워크플로 모두 `concurrency: group: ec2-production-deployment, cancel-in-progress: false` 적용하여 동시 커밋 시 EC2 배포 명령 충돌 방지 및 큐잉 직렬화 보장. + 4. **남은 항목**: + - 5단계: 도메인 준비 시 Cloudflare Free (Full strict) HTTPS 암호화. + +- **AWS EC2 + RDS + S3 운영 배포 6단계 GitHub Actions CI/CD 파이프라인 구축 및 검증 (2026-09-11)**: + 1. **작업명**: 탄력적 IP(`13.124.57.166`) 연결, GitHub Secrets 등록, `.github/workflows/deploy.yml` 배포 파이프라인 구축 및 헬스체크 검증 + 2. **현재 상태**: 완료 (GitHub Actions 워크플로 정상 동작 및 EC2 컨테이너 자동 재배포/헬스체크 200 OK 확인) + 3. **완료된 항목**: + - AWS 탄력적 IP(Elastic IP) 할당 및 EC2(`i-014492690412b656e`) 연결 (`13.124.57.166`). + - GitHub 저장소 Secrets 등록: `EC2_HOST` (`13.124.57.166`), `EC2_USER` (`ubuntu`), `EC2_SSH_KEY` (PEM 개인키). + - EC2 보안 그룹(Security Group) 포트 22번 인바운드 `0.0.0.0/0` 개방 (GitHub Actions 러너 SSH 접속 허용). + - EC2 2GB Linux Swap 메모리 활성화 가이드 (OOM 방어). + - `MemberLoginRequest.java`: Jackson 역직렬화 시 `null`을 원시 `boolean`에 매핑하지 못하던 결함을 래퍼 클래스 `Boolean` 및 방어 메서드(`isRememberMe()`)로 개선. + - `.github/workflows/deploy.yml` 구축 및 동작 검증: + * `Backend CI (Spotless & Test)`: 통과 (MySQL 8.0 테스트 컨테이너 기반) + * `Frontend CI (Build Verification)`: 통과 (Next.js 16 Turbopack 프로덕션 빌드) + * `Deploy to AWS EC2`: 통과 (`appleboy/ssh-action` SSH 접속 ➔ Git reset ➔ Docker Compose 증분 빌드 ➔ 댕글링 이미지 정리 ➔ 헬스체크 루프 HTTP 200 OK 판정) + 4. **남은 항목**: + - 5단계: 도메인 준비 시 Cloudflare Free (Full strict) HTTPS 암호화. + +- **AWS EC2 + RDS + S3 운영 배포 4단계 비공개 S3 이미지 연동 및 프로덕션 실측 100% 완결 (2026-09-11)**: + 1. **작업명**: AWS S3 SDK 연동, 비공개 버킷(`snowthing-media-00001`) 기반 이미지 업로드/다운로드 API 개발, EC2 IAM Role 정책(`snowthing-s3-policy`) 연동 및 인가 실측 + 2. **현재 상태**: 4단계 100% 완료 (EC2 ➔ S3 이미지 업로드 및 로그인 세션 기반 인가 다운로드 실측 성공) + 3. **완료된 항목**: + - `software.amazon.awssdk:s3:2.25.70` AWS Java SDK v2 의존성 추가. + - `S3Config.java`: `DefaultCredentialsProvider` 기반으로 EC2 IAM Role 임시 자격증명 자동 주입 아키텍처 수립 (정적 액세스 키 배제). + - EC2 IAM 역할(`snowthing-rds-connect-policy`)에 인라인 정책 `snowthing-s3-policy` (`s3:PutObject`, `s3:GetObject` on `arn:aws:s3:::snowthing-media-00001/*`) 부여 완료. + - `ErrorCode.java`: `INVALID_FILE_TYPE(FILE_001)`, `FILE_SIZE_EXCEEDED(FILE_002)`, `FILE_NOT_FOUND(FILE_003)`, `FILE_UPLOAD_FAILED(FILE_004)` 비즈니스 에러 코드 정의. + - `BusinessException.java` & `GlobalExceptionHandler.java`: 문자열 리터럴 예외 금지 규칙 준수 기반 표준 비즈니스 예외 핸들러 구축. + - `ImageUploadResponse.java`, `ImageDownloadResponse.java`: 방어적 복사(`byte[].clone()`)를 통한 DTO 불변성 완벽 보장. + - `ImageService.java`: 5MB 파일 크기 제한, 확장자/MIME 화이트리스트(`jpg`, `jpeg`, `png`, `webp`) 검증, 경로 탐색(`..`) 방어, S3 `putObject`/`getObject` 로직 구현. + - `ImageController.java`: `POST /api/v1/images` (인증 필수 업로드), `GET /api/v1/images/**` (인가 필수 조회) 엔드포인트 구현 (비인증/외부 접근 시 401/403 차단). + - `ImageServiceTest.java`: Mockito 기반 격리 단위 테스트 6종 작성 및 100% 통과 (정상 업로드, 5MB 초과 차단, 비허용 확장자 차단, 정상 다운로드, 경로 탐색 차단, 존재하지 않는 파일 404 차단). + - 프로덕션 실측 완료: + * 비인가 다운로드 시도 시 Spring Security에 의한 401 Unauthorized 차단 검증 완료. + * `POST /api/v1/members` 및 `POST /api/v1/auth/login` 인증 세션 획득 성공 (RDS MySQL 연동 확인). + * `POST /api/v1/images` 호출 시 S3 버킷(`snowthing-media-00001`)에 고유 UUID 객체 업로드 성공 (`201 Created`). + * `GET /api/v1/images/**` 호출 시 S3 객체 스트리밍을 통해 HTTP 200 OK 및 원본 데이터 수신 실측 성공. + 4. **남은 항목**: + - 5단계: 도메인 연결 및 Cloudflare Free (Full strict) HTTPS 암호화. + - 6단계: GitHub Actions 기반 자동 배포 파이프라인 구축. + +- **AWS EC2 + RDS 운영 배포 3단계 수동 배포 및 웹 접속 완결 (2026-09-11)**: + 1. **작업명**: EC2 배포 브랜치(`deploy/aws-ec2`) 동기화, RDS 비파괴 스키마/기준데이터 적용, 호스트 Nginx 리버스 프록시 연동 및 실제 웹 접속 검증 + 2. **현재 상태**: 3단계 100% 완료 (EC2 공인 IP `43.202.157.3` 웹 화면 및 백엔드 API 정상 서빙 중) + 3. **완료된 항목**: + - `deploy/aws-ec2` 브랜치 생성 및 GitHub 원격 푸시 완료 (`ddee2fe`, `4426b9c`, `9cedf54`). + - RDS MySQL에 `001_initial_schema.sql` (테이블 11개) 및 `002_reference_data.sql` (스키장 6개, 카테고리 5개 등) 무결성 생성 완료. + - EC2 환경변수 `/etc/snowthing/prod.env` 작성 및 `chmod 600`, 소유권 `ubuntu:ubuntu` 격리 완료. + - AWS Advanced JDBC Wrapper 기반 IAM 무암호 DB 인증으로 Spring Boot 백엔드 RDS 연결 완결. + - Next.js 16 (`--legacy-peer-deps`) 및 Spring Boot 도커 컨테이너 빌드 & `Up` 구동 성공. + - 호스트 Nginx 설치 및 `/` (Next.js 3000), `/api/` (Spring Boot 8080) 리버스 프록시 라우팅 구성 완료. + - `curl -i http://127.0.0.1:8080/api/v1/master/resorts` 200 OK 및 브라우저(`http://43.202.157.3`) 접속 실측 완료. + +- **Sprint 04 댓글 벤치마크 학습정리 문서 보강 및 디렉터리 영문화 완료 (2026-09-09)**: + 1. **디렉터리 영문화**: `benchmark` 하위 한글 폴더를 영문 표준으로 변경 (`실행계획` ➔ `explain-plans`, `쿼리` ➔ `queries`). + 2. **경로 동기화**: `ADR-002-댓글아키텍처.md` 및 `댓글-벤치마크-결과.md` 내 실행계획 경로를 `explain-plans/`로 갱신. + 3. **재현가이드 ➔ README.md 개편 및 내용 보강 (no_ai 톤)**: + - `재현가이드.md`를 `README.md`로 전환하고 실무 개발자 톤으로 4대 핵심 영역(Seed 코드 위치, 실행/초기화 명령어, 데이터 분포 구조, 1K~1M 9대 시나리오 검증 결과 및 인덱스/불변식 요약) 보강 완료. + 4. **`docs/study/sprint04/댓글조회-벤치마크-학습정리.md` 심층 학습서 보강 완결**: + - 5대 학습 목표와 멘토의 실험 의도 및 실무 배경(1K 메모리 착시 vs 1M 운영 장애, estimated/actual rows/loops 해석, 재현 가능 시드, 읽기 이점 vs 쓰기 비용, 운영 DB 안전장치) 기술. + - 1K·10K·100K·1M 9대 시나리오 종합 레이턴시 비교표 및 1M Invisible 인덱스 검증 비교표(226ms vs 36ms, actual rows 200,001 ➔ 2,000) 수록. + - 4대 핵심 결론 및 MySQL InnoDB 물리 엔진 심층 분석(16KB Buffer Pool I/O, B-Tree 수직 Seek 및 수평 Scan 메커니즘, Hotspot 국소 격리, 커버링 인덱스 Clustered Random I/O 차단, `Using filesort` 메모리 정렬 vs 4컬럼 B-Tree 페이지 분할 트레이드오프) 반영. + - 7대 필수 요소 체계(개념, Why, When, How, Pros, Alternatives, Trade-off & 극복 방안) 기반 복합 인덱스 계층 조회 아키텍처 정리 완료. - **Sprint 03 댓글 도메인 메인 브랜치 최종 병합 완료 (2026-09-06)**: 1. **PR #17 (`feature/sprint03-comment` ➔ `main`) 병합 완결**: - Sprint 03 댓글 도메인(생성·조회·수정·삭제 및 하이브리드 프리뷰 아키텍처) 전체 작업물을 `main` 브랜치로 병합 완료 ([PR #17](https://github.com/devikae/snowthing/pull/17) `MERGED`). @@ -1188,3 +1297,14 @@ - `CommentControllerTest`와 `spotlessCheck`는 통과했습니다. - `CommentCreateTest` 16건은 `SNOWTHING_TEST_DB_URL` 미설정 시 실행을 차단하는 기존 MySQL 강제 설정 때문에 Spring Context 생성 전에 실패했습니다. 경계값 변경으로 인한 테스트 assertion 실패는 아닙니다. >>>>>>> origin/feature/sprint03-comment + +- **커뮤니티 UI 디자인 운영 배포 (2026-09-13)**: + - `feature/ui-redesign`의 `02fde60` 커밋을 `deploy/aws-ec2`에 반영해 `12bfe1b`로 배포 브랜치에 포함했습니다. + - 프론트 CI가 성공했고, OIDC + SSM을 통해 EC2 프론트 컨테이너 재빌드·재시작 및 로컬 HTTP 200 헬스체크까지 완료했습니다. + - 운영 도메인 `https://snowthing.org/` 응답이 HTTP 200임을 확인했습니다. 원본 디자인 브랜치는 삭제하지 않았습니다. + +- **운영 CSRF/CORS 검증 및 수정 (2026-09-13)**: + - 프론트엔드 운영 API 주소가 이전 EC2 IP의 HTTP 주소로 남아 있어 `https://snowthing.org`로 교체하고 프론트엔드를 재배포했습니다. + - 운영 도메인에서 CSRF 발급 요청은 200이었지만 로그인 요청이 `403 Invalid CORS request`로 차단되었습니다. 원인은 백엔드 CORS 허용 origin이 `http://localhost:3000`만 포함하고 있었기 때문입니다. + - `https://snowthing.org`, `https://www.snowthing.org`를 허용 origin에 추가하고 백엔드를 OIDC+SSM으로 재배포했습니다. + - 재검증 결과 운영 CSRF 발급은 200, 동일 세션의 로그인 요청은 CORS 차단이 아닌 정상적인 `401 AUTH_001 INVALID_CREDENTIALS`를 반환했습니다. 즉 브라우저와 백엔드 사이의 CORS/CSRF 진입 문제는 해결되었습니다. diff --git a/docs/specs/post-compose-detail-redesign.md b/docs/specs/post-compose-detail-redesign.md new file mode 100644 index 0000000..53a80d3 --- /dev/null +++ b/docs/specs/post-compose-detail-redesign.md @@ -0,0 +1,26 @@ +# 게시글 작성·상세 화면 재설계 + +## 목표 + +두 번째 Stitch 시안의 Modern Alpine Soft UI를 게시글 작성과 상세 화면에 적용한다. API와 권한 정책은 변경하지 않고 사용자가 실제로 작성, 조회, 반응, 댓글·답글, 수정·삭제할 수 있는 영역을 우선 완성한다. + +## 확정 범위 + +- 글쓰기 제목 옆 `REAL ANONYMOUS` 배지와 제목 아래 개인정보 안내 문구는 표시하지 않는다. +- 글쓰기의 카테고리, 익명 비밀번호, 제목, Toast UI 본문, 이미지 URL, 취소·등록 기능을 유지한다. +- 상세의 조회, 작성자, 수정·삭제 권한, 이미지, 추천·비추천, 댓글·답글과 댓글 수정·삭제·더보기 기능을 유지한다. +- 시안의 리조트 태그, 설질, 카풀 등 서버 데이터가 없는 부분은 실제 기능으로 오해되지 않는 정적인 보조 카드로만 둔다. +- 모바일에서는 기능 영역을 먼저 배치하고 보조 카드는 아래로 이동한다. + +## 구현 원칙 + +기존 요청 함수와 상태는 보존하고 렌더링 구조와 공통 CSS만 재구성한다. API가 지원하지 않는 정렬·신고·파일 직접 업로드·임시저장은 동작하는 컨트롤로 만들지 않는다. + +## 검증 + +- 익명 및 일반 게시글 작성 후 상세 페이지 이동 +- 실제 상세 데이터와 이미지 표시 +- 추천·비추천 요청 +- 댓글·답글 작성과 수정·삭제·더보기 +- 모바일 및 데스크톱 반응형 화면 +- 프로덕션 빌드와 변경 파일 ESLint diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..816abc4 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,44 @@ +# ========================================== +# 1단계: 빌드 스테이지 (Node.js 20 Alpine) +# ========================================== +FROM node:20-alpine AS builder +WORKDIR /app + +# 패키지 의존성 파일 복사 및 설치 (npm ci로 정확한 잠금 버전 설치) +COPY package.json package-lock.json ./ +RUN npm ci --legacy-peer-deps + +# 소스코드 전체 복사 +COPY . . + +# 빌드 타임에 실제 도메인 주소(HTTPS) 주입 (예: https://snowthing.shop) +ARG NEXT_PUBLIC_API_BASE_URL +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL + +# Next.js 프로덕션 빌드 실행 +RUN npm run build + +# ========================================== +# 2단계: 실행 스테이지 (초경량 런타임) +# ========================================== +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 + +# 보안을 위한 비root 유저 생성 +RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs + +# 프로덕션 실행에 필요한 결과물만 복사 +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/package-lock.json ./package-lock.json +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/.next ./.next + +USER nextjs + +EXPOSE 3000 + +# 프로덕션 서버 구동 (npm run start) +CMD ["npm", "run", "start"] diff --git a/frontend/app/components/SiteChrome.tsx b/frontend/app/components/SiteChrome.tsx index 36781d9..17e2c6c 100644 --- a/frontend/app/components/SiteChrome.tsx +++ b/frontend/app/components/SiteChrome.tsx @@ -1,11 +1,12 @@ "use client"; import Link from "next/link"; -import { useEffect, useState } from "react"; +import { FormEvent, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; import { csrfFetch } from "../lib/csrfFetch"; import { API_ENDPOINTS } from "../lib/api"; -export type ActiveNav = "home" | "posts" | "resort" | "profile" | "login" | "signup"; +export type ActiveNav = "home" | "posts" | "free" | "anonymous" | "gear" | "resort" | "profile" | "login" | "signup"; interface MemberUser { publicId: string; @@ -14,36 +15,42 @@ interface MemberUser { profileImageUrl: string | null; } -const navItems: { href: string; label: string; key: ActiveNav }[] = [ - { href: "/resort", label: "Resorts", key: "resort" }, - { href: "/posts", label: "Community", key: "posts" }, - { href: "/profile", label: "Rider Card", key: "profile" }, -]; +interface NavItem { + href: string; + label: string; + key: string; +} -const categories = [ - { href: "/posts", label: "전체 게시판", icon: "forum", key: "all" }, - { href: "/posts?category=FREE", label: "자유 게시판", icon: "terrain", key: "free" }, - { href: "/posts?category=ANONYMOUS", label: "익명 게시판", icon: "visibility_off", key: "anonymous" }, - { href: "/posts?category=QNA", label: "장비 Q&A", icon: "help", key: "qna" }, - { href: "/posts?category=FOOD", label: "리조트 맛집", icon: "restaurant", key: "food" }, +const navItems: NavItem[] = [ + { href: "/posts", label: "전체글", key: "posts" }, + { href: "/posts?sort=popular", label: "실시간 베스트", key: "best" }, + { href: "/posts?category=FREE", label: "자유게시판", key: "free" }, + { href: "/posts?category=ANONYMOUS", label: "익명게시판", key: "anonymous" }, + { href: "/resort", label: "실시간 설질/웹캠", key: "resort" }, + { href: "/posts?category=QNA", label: "장비·테크닉", key: "gear" }, + { href: "/#carpool", label: "카풀/동행", key: "carpool" }, + { href: "/#market", label: "중고장터", key: "market" }, ]; +const mobileItems = [ + { href: "/", label: "홈", icon: "home", key: "home" }, + { href: "/posts?sort=popular", label: "베스트", icon: "local_fire_department", key: "best" }, + { href: "/resort", label: "실시간 설질", icon: "ac_unit", key: "resort" }, + { href: "/#carpool", label: "카풀", icon: "directions_car", key: "carpool" }, + { href: "/posts?category=ANONYMOUS", label: "익명", icon: "forum", key: "anonymous" }, +] as const; + export function TopNav({ active = "home" }: { active?: ActiveNav }) { + const router = useRouter(); const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); useEffect(() => { - (async () => { + void (async () => { try { - const res = await fetch(API_ENDPOINTS.members.me, { - credentials: "include", - }); - if (res.ok) { - const data = await res.json(); - setUser(data); - } else { - setUser(null); - } + const response = await fetch(API_ENDPOINTS.members.me, { credentials: "include" }); + setUser(response.ok ? await response.json() : null); } catch { setUser(null); } finally { @@ -52,13 +59,15 @@ export function TopNav({ active = "home" }: { active?: ActiveNav }) { })(); }, []); + const handleSearch = (event: FormEvent) => { + event.preventDefault(); + const keyword = search.trim(); + if (keyword) router.push(`/posts?keyword=${encodeURIComponent(keyword)}`); + }; + const handleLogout = async () => { try { - await csrfFetch(API_ENDPOINTS.auth.logout, { - method: "POST", - }); - } catch { - // ignore + await csrfFetch(API_ENDPOINTS.auth.logout, { method: "POST" }); } finally { setUser(null); window.location.href = "/"; @@ -66,107 +75,95 @@ export function TopNav({ active = "home" }: { active?: ActiveNav }) { }; return ( -
-
-
- - SnowThing - - + <> +
+
+
+ 공지 + 24/25 시즌방 인원 구인 및 리조트 실시간 슬로프 제보 게시판 이용 수칙 안내 +
+
+ 강원권 야간 정설 완료 + 실시간 웹캠 센터 + 출석체크 +
-
- {!loading && ( - <> - {user ? ( -
- - {user.nickname}님 - - -
- ) : ( +
+ +
+
+
+ + + SnowThing + + +
+ +
+
+ search + setSearch(event.target.value)} placeholder="슬로프, 장비, 카풀 검색" aria-label="통합 검색" /> +
+ + edit글쓰기 + + + {!loading && (user ? ( +
+ + {user.profileImageUrl ? : {user.nickname.slice(0, 1)}} + + +
+ ) : ( + 로그인 + ))} +
-
-
- ); -} + -export function SideCategories({ active = "all" }: { active?: string }) { - return ( - + ); } +export function SideCategories({ active = "all" }: { active?: string }) { + return