From f18a976ca96324221580174d0b4a0b6d695f28b9 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 6 Nov 2025 10:21:59 +0900 Subject: [PATCH 01/19] =?UTF-8?q?feat=20:=20deploy=20=EB=B0=8F=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=9D=BC=EB=B6=80=20=EB=B3=80=EA=B2=BD,=20?= =?UTF-8?q?=EA=B8=80=EB=A1=9C=EB=B2=8C=20exception=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=95=A1=EC=B8=84=EC=97=90=EC=9D=B4=ED=84=B0=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=EB=8A=94=20=EA=B7=B8=EB=83=A5=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?throw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.prod.yml => docker-compose.yml | 10 ++- monew-api/build.gradle | 3 + .../exception/GlobalExceptionHandler.java | 7 +- .../src/main/resources/logback-spring.xml | 7 ++ monew-batch/build.gradle | 3 + .../monew_batch/MonewBatchApplication.java | 12 ++-- .../user/scheduler/DeletionScheduler.java | 3 +- .../src/main/resources/application-prod.yml | 2 +- .../src/main/resources/prometheus.yml | 68 +++++++++++++++++-- 9 files changed, 99 insertions(+), 16 deletions(-) rename docker-compose.prod.yml => docker-compose.yml (96%) diff --git a/docker-compose.prod.yml b/docker-compose.yml similarity index 96% rename from docker-compose.prod.yml rename to docker-compose.yml index bfb0964..70ddb83 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.yml @@ -136,10 +136,11 @@ services: # Prometheus (Metrics Collector) prometheus: - image: prom/prometheus:latest + build: + context: . + dockerfile: Dockerfile.prom + image: monew-prometheus:-latest container_name: monew-prometheus - volumes: - - ./monew-monitor/src/main/resources/prometheus.yml:/etc/prometheus/prometheus.yml:ro ports: - "9090:9090" networks: @@ -148,6 +149,9 @@ services: # Grafana (Visualization) grafana: + build: + context: . + dockerfile: Dockerfile.grafana image: grafana/grafana:latest container_name: monew-grafana environment: diff --git a/monew-api/build.gradle b/monew-api/build.gradle index b84ba3a..c4f5378 100644 --- a/monew-api/build.gradle +++ b/monew-api/build.gradle @@ -19,4 +19,7 @@ dependencies { implementation 'org.springframework.security:spring-security-crypto' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' implementation 'org.apache.commons:commons-text:1.10.0' // 유사도 계산용 + + implementation("org.springframework.boot:spring-boot-starter-actuator") + runtimeOnly("io.micrometer:micrometer-registry-prometheus") } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/GlobalExceptionHandler.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/GlobalExceptionHandler.java index 00b7400..5688edd 100644 --- a/monew-api/src/main/java/com/monew/monew_api/common/exception/GlobalExceptionHandler.java +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/GlobalExceptionHandler.java @@ -56,7 +56,12 @@ public ResponseEntity handleValidationExceptions(MethodArgumentNo } @ExceptionHandler(Exception.class) - public ResponseEntity handleUnexpectedException(Exception e, HttpServletRequest request) { + public ResponseEntity handleUnexpectedException(Exception e, HttpServletRequest request) throws Exception { + + if (request.getRequestURI().startsWith("/actuator")) { + throw e; + } + log.error("[서버 내부 오류] 예외 타입: {}, 메시지: {}, URI: {}", e.getClass().getSimpleName(), e.getMessage(), diff --git a/monew-api/src/main/resources/logback-spring.xml b/monew-api/src/main/resources/logback-spring.xml index 45c63f7..3b23cdf 100644 --- a/monew-api/src/main/resources/logback-spring.xml +++ b/monew-api/src/main/resources/logback-spring.xml @@ -32,8 +32,15 @@ + + + ${LOG_PATTERN} + + + + diff --git a/monew-batch/build.gradle b/monew-batch/build.gradle index 98d2cc3..7997905 100644 --- a/monew-batch/build.gradle +++ b/monew-batch/build.gradle @@ -25,4 +25,7 @@ dependencies { runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'com.h2database:h2' testImplementation 'org.springframework.batch:spring-batch-test' + + implementation("org.springframework.boot:spring-boot-starter-actuator") + runtimeOnly("io.micrometer:micrometer-registry-prometheus") } \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java b/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java index c092f5c..bf2a146 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java @@ -11,10 +11,14 @@ import java.util.TimeZone; @SpringBootApplication( - scanBasePackages = { - "com.monew.monew_batch", - "com.monew.monew_api.article.repository", - } + scanBasePackages = { + "com.monew.monew_batch", + "com.monew.monew_api.article.repository", + }, + exclude = { + org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration.class, + org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration.class + } ) @EntityScan(basePackages = "com.monew.monew_api") @EnableJpaRepositories(basePackages = "com.monew.monew_api") diff --git a/monew-batch/src/main/java/com/monew/monew_batch/user/scheduler/DeletionScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/user/scheduler/DeletionScheduler.java index 9480e84..b2e4c53 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/user/scheduler/DeletionScheduler.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/user/scheduler/DeletionScheduler.java @@ -22,7 +22,8 @@ public class DeletionScheduler { * [요구사항] Soft delete 후 1일 경과한 사용자를 영구 삭제 * [프로토타입] 5초마다 체크하여 5분 경과한 사용자 삭제 */ - @Scheduled(fixedDelay = 5000) +// @Scheduled(fixedDelay = 50000) + @Scheduled(cron = "0 10 5 * * *", zone = "Asia/Seoul") public void runUserDeletionJob() throws Exception { log.info("==== Starting User Deletion Job ===="); diff --git a/monew-batch/src/main/resources/application-prod.yml b/monew-batch/src/main/resources/application-prod.yml index e39c3ae..f4763f4 100644 --- a/monew-batch/src/main/resources/application-prod.yml +++ b/monew-batch/src/main/resources/application-prod.yml @@ -66,4 +66,4 @@ aws: monew: api: - url: ${MONEW_API_URL} # 배포 후 추가 필요 \ No newline at end of file + url: ${http://monew-api.monew.local:8080} \ No newline at end of file diff --git a/monew-monitor/src/main/resources/prometheus.yml b/monew-monitor/src/main/resources/prometheus.yml index 606a695..f24307e 100644 --- a/monew-monitor/src/main/resources/prometheus.yml +++ b/monew-monitor/src/main/resources/prometheus.yml @@ -1,18 +1,74 @@ +#global: +# scrape_interval: 10s # 10초마다 메트릭 수집 + +# local +#scrape_configs: +# - job_name: 'monew-api' +# metrics_path: '/actuator/prometheus' +# static_configs: +# - targets: ['host.docker.internal:8080'] +# +# - job_name: 'monew-batch' +# metrics_path: '/actuator/prometheus' +# static_configs: +# - targets: ['host.docker.internal:8081'] +# +# - job_name: 'monew-monitor' +# metrics_path: '/actuator/prometheus' +# static_configs: +# - targets: ['host.docker.internal:8082'] + +# 배포 +#scrape_configs: +# - job_name: 'monew-api' +# metrics_path: '/api/actuator/prometheus' +# static_configs: +# - targets: ['monew-app-alb-721921608.ap-northeast-2.elb.amazonaws.com'] +# scheme: http +# +# - job_name: 'monew-batch' +# metrics_path: '/batch/actuator/prometheus' +# static_configs: +# - targets: ['monew-app-alb-721921608.ap-northeast-2.elb.amazonaws.com'] +# scheme: http +# +# - job_name: 'monew-monitor' +# metrics_path: '/monitor/actuator/prometheus' +# static_configs: +# - targets: ['monew-app-alb-721921608.ap-northeast-2.elb.amazonaws.com'] +# scheme: http + global: scrape_interval: 10s # 10초마다 메트릭 수집 +# (로컬용은 그대로 주석 유지) + +# 배포 scrape_configs: + # 0) Prometheus 자기 자신 (프리픽스 사용하므로 metrics_path 변경) + - job_name: 'prometheus' + metrics_path: /prometheus/metrics + static_configs: + - targets: ['localhost:9090'] + scheme: http + + # 1) monew-api - job_name: 'monew-api' - metrics_path: '/actuator/prometheus' + metrics_path: /actuator/prometheus static_configs: - - targets: ['host.docker.internal:8080'] + - targets: ['monew-alb-721921608.ap-northeast-2.elb.amazonaws.com'] + scheme: http + # 2) monew-batch - job_name: 'monew-batch' - metrics_path: '/actuator/prometheus' + metrics_path: /batch/actuator/prometheus static_configs: - - targets: ['host.docker.internal:8081'] + - targets: ['monew-alb-721921608.ap-northeast-2.elb.amazonaws.com'] + scheme: http + # 3) monew-monitor - job_name: 'monew-monitor' - metrics_path: '/actuator/prometheus' + metrics_path: /monitor/actuator/prometheus static_configs: - - targets: ['host.docker.internal:8082'] \ No newline at end of file + - targets: ['monew-alb-721921608.ap-northeast-2.elb.amazonaws.com'] + scheme: http \ No newline at end of file From 9b66faec6d8cebffd8712ca821b70aff42033845 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 6 Nov 2025 10:22:09 +0900 Subject: [PATCH 02/19] =?UTF-8?q?feat=20:=20deploy=20=EB=B0=8F=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=9D=BC=EB=B6=80=20=EB=B3=80=EA=B2=BD,=20?= =?UTF-8?q?=EA=B8=80=EB=A1=9C=EB=B2=8C=20exception=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=95=A1=EC=B8=84=EC=97=90=EC=9D=B4=ED=84=B0=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=EB=8A=94=20=EA=B7=B8=EB=83=A5=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?throw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 248 +++++++++++++++++++++++++++++++++++ Dockerfile.grafana | 145 ++++++++++++++++++++ Dockerfile.prom | 7 + 3 files changed, 400 insertions(+) create mode 100644 .github/workflows/deploy.yml create mode 100644 Dockerfile.grafana create mode 100644 Dockerfile.prom diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..df71529 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,248 @@ +name: CD - Deploy to ECS + +on: + push: + branches: + - release + workflow_dispatch: + +env: + AWS_REGION: ap-northeast-2 + ECS_CLUSTER: monew-cluster + +jobs: + build-and-push: + runs-on: ubuntu-latest + strategy: + matrix: + service: + - name: api + dockerfile: Dockerfile.api + ecr_repo: monew-api + ecs_service: monew-api-service + task_definition: monew-api-task + - name: batch + dockerfile: Dockerfile.batch + ecr_repo: monew-batch + ecs_service: monew-batch-service + task_definition: monew-batch-task + - name: monitor + dockerfile: Dockerfile.monitor + ecr_repo: monew-monitor + ecs_service: monew-monitor-service + task_definition: monew-monitor-task + - name: prometheus + dockerfile: Dockerfile.prom + ecr_repo: monew-prometheus + ecs_service: monew-prometheus-service + task_definition: monew-prometheus-task + - name: grafana + dockerfile: Dockerfile.grafana + ecr_repo: monew-grafana + ecs_service: monew-grafana-service + task_definition: monew-grafana-task + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # ======================================== + # ECR 로그인 (Public 또는 Private) + # ======================================== + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + # ======================================== + # Docker 이미지 빌드 & 푸시 + # ======================================== + - name: Build, tag, and push ${{ matrix.service.name }} + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + IMAGE_TAG: ${{ github.sha }} + run: | + docker build \ + -f ${{ matrix.service.dockerfile }} \ + -t $ECR_REGISTRY/${{ matrix.service.ecr_repo }}:$IMAGE_TAG \ + -t $ECR_REGISTRY/${{ matrix.service.ecr_repo }}:latest \ + . + + docker push $ECR_REGISTRY/${{ matrix.service.ecr_repo }}:$IMAGE_TAG + docker push $ECR_REGISTRY/${{ matrix.service.ecr_repo }}:latest + + # ======================================== + # ECS 배포 - API & 모니터링 (병렬) + # ======================================== + deploy-main: + runs-on: ubuntu-latest + needs: build-and-push + strategy: + matrix: + service: + - name: api + ecs_service: monew-api-service + task_definition: monew-api-task + ecr_repo: monew-api + - name: monitor + ecs_service: monew-monitor-service + task_definition: monew-monitor-task + ecr_repo: monew-monitor + - name: prometheus + ecs_service: monew-prometheus-service + task_definition: monew-prometheus-task + ecr_repo: monew-prometheus + - name: grafana + ecs_service: monew-grafana-service + task_definition: monew-grafana-task + ecr_repo: monew-grafana + + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + # ======================================== + # 태스크 정의 업데이트 + # ======================================== + - name: Update task definition + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + IMAGE_TAG: ${{ github.sha }} + run: | + # 현재 태스크 정의 가져오기 + TASK_DEFINITION=$(aws ecs describe-task-definition \ + --task-definition ${{ matrix.service.task_definition }} \ + --query 'taskDefinition' \ + --output json) + + # 새 이미지로 업데이트 + NEW_TASK_DEFINITION=$(echo $TASK_DEFINITION | jq \ + --arg IMAGE "$ECR_REGISTRY/${{ matrix.service.ecr_repo }}:$IMAGE_TAG" \ + '.containerDefinitions[0].image = $IMAGE | + del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)') + + # 새 태스크 정의 등록 + NEW_TASK_ARN=$(aws ecs register-task-definition \ + --cli-input-json "$NEW_TASK_DEFINITION" | \ + jq -r '.taskDefinition.taskDefinitionArn') + + echo "NEW_TASK_ARN=$NEW_TASK_ARN" >> $GITHUB_ENV + + # ======================================== + # 서비스 재시작 + # ======================================== + - name: Update ECS service + run: | + aws ecs update-service \ + --cluster ${{ env.ECS_CLUSTER }} \ + --service ${{ matrix.service.ecs_service }} \ + --task-definition ${{ env.NEW_TASK_ARN }} \ + --desired-count 1 \ + --force-new-deployment + + echo "✅ ${{ matrix.service.name }} service deployed!" + + # ======================================== + # 배포 상태 확인 (선택사항) + # ======================================== + - name: Wait for service stability + run: | + echo "Waiting for service to be stable..." + aws ecs wait services-stable \ + --cluster ${{ env.ECS_CLUSTER }} \ + --services ${{ matrix.service.ecs_service }} + + echo "🚀 ${{ matrix.service.name }} deployment completed!" + + # ======================================== + # ECS 배포 - Batch (API 안정화 후) + # ======================================== + deploy-batch: + runs-on: ubuntu-latest + needs: deploy-main # ⭐ API 배포 완료 후 실행 + strategy: + matrix: + service: + - name: batch + ecs_service: monew-batch-service + task_definition: monew-batch-task + ecr_repo: monew-batch + + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + # ======================================== + # 태스크 정의 업데이트 + # ======================================== + - name: Update task definition + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + IMAGE_TAG: ${{ github.sha }} + run: | + # 현재 태스크 정의 가져오기 + TASK_DEFINITION=$(aws ecs describe-task-definition \ + --task-definition ${{ matrix.service.task_definition }} \ + --query 'taskDefinition' \ + --output json) + + # 새 이미지로 업데이트 + NEW_TASK_DEFINITION=$(echo $TASK_DEFINITION | jq \ + --arg IMAGE "$ECR_REGISTRY/${{ matrix.service.ecr_repo }}:$IMAGE_TAG" \ + '.containerDefinitions[0].image = $IMAGE | + del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)') + + # 새 태스크 정의 등록 + NEW_TASK_ARN=$(aws ecs register-task-definition \ + --cli-input-json "$NEW_TASK_DEFINITION" | \ + jq -r '.taskDefinition.taskDefinitionArn') + + echo "NEW_TASK_ARN=$NEW_TASK_ARN" >> $GITHUB_ENV + + # ======================================== + # 서비스 재시작 + # ======================================== + - name: Update ECS service + run: | + aws ecs update-service \ + --cluster ${{ env.ECS_CLUSTER }} \ + --service ${{ matrix.service.ecs_service }} \ + --task-definition ${{ env.NEW_TASK_ARN }} \ + --desired-count 1 \ + --force-new-deployment + + echo "✅ ${{ matrix.service.name }} service deployed!" + + # ======================================== + # 배포 상태 확인 (선택사항) + # ======================================== + - name: Wait for service stability + run: | + echo "Waiting for service to be stable..." + aws ecs wait services-stable \ + --cluster ${{ env.ECS_CLUSTER }} \ + --services ${{ matrix.service.ecs_service }} + + echo "🚀 ${{ matrix.service.name }} deployment completed!" \ No newline at end of file diff --git a/Dockerfile.grafana b/Dockerfile.grafana new file mode 100644 index 0000000..971650c --- /dev/null +++ b/Dockerfile.grafana @@ -0,0 +1,145 @@ +name: CD - Deploy to ECS + +on: + push: + branches: + - release + workflow_dispatch: + +env: + AWS_REGION: ap-northeast-2 + ECS_CLUSTER: monew-cluster + # Public ECR 리포지토리 + ECR_REPOSITORY: public.ecr.aws/s7e2q2h9/monew + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + strategy: + matrix: + service: + - name: api + dockerfile: Dockerfile.api + tag: monew-api + task_definition: monew-api-task + ecs_service: monew-api-service + - name: batch + dockerfile: Dockerfile.batch + tag: monew-batch + task_definition: monew-batch-task + ecs_service: monew-batch-service + - name: monitor + dockerfile: Dockerfile.monitor + tag: monew-monitor + task_definition: monew-monitor-task + ecs_service: monew-monitor-service + - name: prometheus + dockerfile: Dockerfile.prom + tag: prometheus + task_definition: monew-prometheus-task + ecs_service: monew-prometheus-service + - name: grafana + dockerfile: Dockerfile.grafana + tag: grafana + task_definition: monew-grafana-task + ecs_service: monew-grafana-service + + steps: + # ======================================== + # 1. 코드 체크아웃 + # ======================================== + - name: Checkout code + uses: actions/checkout@v4 + + # ======================================== + # 2. Public ECR 로그인 (us-east-1) + # ======================================== + - name: Configure AWS credentials for Public ECR + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: us-east-1 # Public ECR은 us-east-1만 사용 + + - name: Login to Public ECR + run: | + aws ecr-public get-login-password --region us-east-1 | \ + docker login --username AWS --password-stdin public.ecr.aws + + # ======================================== + # 3. Docker 이미지 빌드 & 푸시 + # ======================================== + - name: Build and push ${{ matrix.service.name }} image + run: | + # 빌드 + docker build \ + -f ${{ matrix.service.dockerfile }} \ + -t ${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-${{ github.sha }} \ + -t ${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-latest \ + . + + # 푸시 + docker push ${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-${{ github.sha }} + docker push ${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-latest + + # ======================================== + # 4. ECS 배포 (ap-northeast-2) + # ======================================== + - name: Configure AWS credentials for ECS + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Stop ECS service (프리티어 최적화) + run: | + aws ecs update-service \ + --cluster ${{ env.ECS_CLUSTER }} \ + --service ${{ matrix.service.ecs_service }} \ + --desired-count 0 + + echo "Waiting for tasks to stop..." + aws ecs wait services-stable \ + --cluster ${{ env.ECS_CLUSTER }} \ + --services ${{ matrix.service.ecs_service }} + + - name: Update task definition + run: | + # 현재 태스크 정의 가져오기 + TASK_DEFINITION=$(aws ecs describe-task-definition \ + --task-definition ${{ matrix.service.task_definition }} \ + --query 'taskDefinition') + + # 새 이미지로 업데이트 + NEW_TASK_DEFINITION=$(echo $TASK_DEFINITION | jq \ + --arg IMAGE "${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-latest" \ + '.containerDefinitions[0].image = $IMAGE | + del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)') + + # 새 태스크 정의 등록 + NEW_TASK_ARN=$(aws ecs register-task-definition \ + --cli-input-json "$NEW_TASK_DEFINITION" | \ + jq -r '.taskDefinition.taskDefinitionArn') + + echo "NEW_TASK_ARN=$NEW_TASK_ARN" >> $GITHUB_ENV + + - name: Start ECS service + run: | + aws ecs update-service \ + --cluster ${{ env.ECS_CLUSTER }} \ + --service ${{ matrix.service.ecs_service }} \ + --task-definition ${{ env.NEW_TASK_ARN }} \ + --desired-count 1 \ + --force-new-deployment + + echo "✅ ${{ matrix.service.name }} service deployed!" + + - name: Wait for service stability + run: | + echo "Waiting for service to be stable..." + aws ecs wait services-stable \ + --cluster ${{ env.ECS_CLUSTER }} \ + --services ${{ matrix.service.ecs_service }} + + echo "🚀 ${{ matrix.service.name }} deployment completed!" \ No newline at end of file diff --git a/Dockerfile.prom b/Dockerfile.prom new file mode 100644 index 0000000..51c3ac6 --- /dev/null +++ b/Dockerfile.prom @@ -0,0 +1,7 @@ +FROM prom/prometheus:latest + +# Custom prometheus.yml 복사 +COPY monew-monitor/src/main/resources/prometheus.yml /etc/prometheus/prometheus.yml + +# 포트 노출 +EXPOSE 9090 \ No newline at end of file From 988e409defd114bdcdf8b5bdc7a5873ce2de6316 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 6 Nov 2025 10:56:46 +0900 Subject: [PATCH 03/19] =?UTF-8?q?fix=20:=20grafana=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20deploy=20=EB=82=B4=EC=9A=A9=EC=9D=B4?= =?UTF-8?q?=20=EB=8D=AE=EC=97=AC=EC=A7=84=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile.grafana | 149 ++------------------------------------------- 1 file changed, 6 insertions(+), 143 deletions(-) diff --git a/Dockerfile.grafana b/Dockerfile.grafana index 971650c..0671b26 100644 --- a/Dockerfile.grafana +++ b/Dockerfile.grafana @@ -1,145 +1,8 @@ -name: CD - Deploy to ECS +FROM grafana/grafana:latest -on: - push: - branches: - - release - workflow_dispatch: +# 환경변수 설정 +ENV GF_SECURITY_ADMIN_USER=admin \ + GF_SECURITY_ADMIN_PASSWORD=admin -env: - AWS_REGION: ap-northeast-2 - ECS_CLUSTER: monew-cluster - # Public ECR 리포지토리 - ECR_REPOSITORY: public.ecr.aws/s7e2q2h9/monew - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - strategy: - matrix: - service: - - name: api - dockerfile: Dockerfile.api - tag: monew-api - task_definition: monew-api-task - ecs_service: monew-api-service - - name: batch - dockerfile: Dockerfile.batch - tag: monew-batch - task_definition: monew-batch-task - ecs_service: monew-batch-service - - name: monitor - dockerfile: Dockerfile.monitor - tag: monew-monitor - task_definition: monew-monitor-task - ecs_service: monew-monitor-service - - name: prometheus - dockerfile: Dockerfile.prom - tag: prometheus - task_definition: monew-prometheus-task - ecs_service: monew-prometheus-service - - name: grafana - dockerfile: Dockerfile.grafana - tag: grafana - task_definition: monew-grafana-task - ecs_service: monew-grafana-service - - steps: - # ======================================== - # 1. 코드 체크아웃 - # ======================================== - - name: Checkout code - uses: actions/checkout@v4 - - # ======================================== - # 2. Public ECR 로그인 (us-east-1) - # ======================================== - - name: Configure AWS credentials for Public ECR - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} - aws-region: us-east-1 # Public ECR은 us-east-1만 사용 - - - name: Login to Public ECR - run: | - aws ecr-public get-login-password --region us-east-1 | \ - docker login --username AWS --password-stdin public.ecr.aws - - # ======================================== - # 3. Docker 이미지 빌드 & 푸시 - # ======================================== - - name: Build and push ${{ matrix.service.name }} image - run: | - # 빌드 - docker build \ - -f ${{ matrix.service.dockerfile }} \ - -t ${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-${{ github.sha }} \ - -t ${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-latest \ - . - - # 푸시 - docker push ${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-${{ github.sha }} - docker push ${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-latest - - # ======================================== - # 4. ECS 배포 (ap-northeast-2) - # ======================================== - - name: Configure AWS credentials for ECS - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} - aws-region: ${{ env.AWS_REGION }} - - - name: Stop ECS service (프리티어 최적화) - run: | - aws ecs update-service \ - --cluster ${{ env.ECS_CLUSTER }} \ - --service ${{ matrix.service.ecs_service }} \ - --desired-count 0 - - echo "Waiting for tasks to stop..." - aws ecs wait services-stable \ - --cluster ${{ env.ECS_CLUSTER }} \ - --services ${{ matrix.service.ecs_service }} - - - name: Update task definition - run: | - # 현재 태스크 정의 가져오기 - TASK_DEFINITION=$(aws ecs describe-task-definition \ - --task-definition ${{ matrix.service.task_definition }} \ - --query 'taskDefinition') - - # 새 이미지로 업데이트 - NEW_TASK_DEFINITION=$(echo $TASK_DEFINITION | jq \ - --arg IMAGE "${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-latest" \ - '.containerDefinitions[0].image = $IMAGE | - del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)') - - # 새 태스크 정의 등록 - NEW_TASK_ARN=$(aws ecs register-task-definition \ - --cli-input-json "$NEW_TASK_DEFINITION" | \ - jq -r '.taskDefinition.taskDefinitionArn') - - echo "NEW_TASK_ARN=$NEW_TASK_ARN" >> $GITHUB_ENV - - - name: Start ECS service - run: | - aws ecs update-service \ - --cluster ${{ env.ECS_CLUSTER }} \ - --service ${{ matrix.service.ecs_service }} \ - --task-definition ${{ env.NEW_TASK_ARN }} \ - --desired-count 1 \ - --force-new-deployment - - echo "✅ ${{ matrix.service.name }} service deployed!" - - - name: Wait for service stability - run: | - echo "Waiting for service to be stable..." - aws ecs wait services-stable \ - --cluster ${{ env.ECS_CLUSTER }} \ - --services ${{ matrix.service.ecs_service }} - - echo "🚀 ${{ matrix.service.name }} deployment completed!" \ No newline at end of file +# 포트 노출 +EXPOSE 3000 \ No newline at end of file From ed801c9c33d584db4a0ee5394391eeb95e912dc1 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 6 Nov 2025 11:06:14 +0900 Subject: [PATCH 04/19] =?UTF-8?q?fix=20:=20repo=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 188 +++++++---------------------------- 1 file changed, 37 insertions(+), 151 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index df71529..929ad8c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,208 +9,100 @@ on: env: AWS_REGION: ap-northeast-2 ECS_CLUSTER: monew-cluster + ECR_REPOSITORY: public.ecr.aws/s7e2q2h9/monew jobs: - build-and-push: + build-and-deploy: runs-on: ubuntu-latest strategy: matrix: service: - name: api dockerfile: Dockerfile.api - ecr_repo: monew-api - ecs_service: monew-api-service + tag: monew-api task_definition: monew-api-task + ecs_service: monew-api-service - name: batch dockerfile: Dockerfile.batch - ecr_repo: monew-batch - ecs_service: monew-batch-service + tag: monew-batch task_definition: monew-batch-task + ecs_service: monew-batch-service - name: monitor dockerfile: Dockerfile.monitor - ecr_repo: monew-monitor - ecs_service: monew-monitor-service + tag: monew-monitor task_definition: monew-monitor-task + ecs_service: monew-monitor-service - name: prometheus dockerfile: Dockerfile.prom - ecr_repo: monew-prometheus - ecs_service: monew-prometheus-service + tag: prometheus task_definition: monew-prometheus-task + ecs_service: monew-prometheus-service - name: grafana dockerfile: Dockerfile.grafana - ecr_repo: monew-grafana - ecs_service: monew-grafana-service + tag: grafana task_definition: monew-grafana-task + ecs_service: monew-grafana-service steps: + # ======================================== + # 1. 코드 체크아웃 + # ======================================== - name: Checkout code uses: actions/checkout@v4 # ======================================== - # ECR 로그인 (Public 또는 Private) + # 2. Public ECR 로그인 (us-east-1) + # ⚠️ 중요: Public ECR은 us-east-1만 사용! # ======================================== - - name: Configure AWS credentials + - name: Configure AWS credentials for Public ECR uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} - aws-region: ${{ env.AWS_REGION }} + aws-region: us-east-1 # ⭐ Public ECR은 us-east-1만 사용 - - name: Login to Amazon ECR - id: login-ecr - uses: aws-actions/amazon-ecr-login@v2 + - name: Login to Public ECR + run: | + aws ecr-public get-login-password --region us-east-1 | \ + docker login --username AWS --password-stdin public.ecr.aws # ======================================== - # Docker 이미지 빌드 & 푸시 + # 3. Docker 이미지 빌드 & 푸시 # ======================================== - - name: Build, tag, and push ${{ matrix.service.name }} - env: - ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} - IMAGE_TAG: ${{ github.sha }} + - name: Build and push ${{ matrix.service.name }} image run: | + # 빌드 docker build \ -f ${{ matrix.service.dockerfile }} \ - -t $ECR_REGISTRY/${{ matrix.service.ecr_repo }}:$IMAGE_TAG \ - -t $ECR_REGISTRY/${{ matrix.service.ecr_repo }}:latest \ + -t ${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-${{ github.sha }} \ + -t ${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-latest \ . - docker push $ECR_REGISTRY/${{ matrix.service.ecr_repo }}:$IMAGE_TAG - docker push $ECR_REGISTRY/${{ matrix.service.ecr_repo }}:latest - - # ======================================== - # ECS 배포 - API & 모니터링 (병렬) - # ======================================== - deploy-main: - runs-on: ubuntu-latest - needs: build-and-push - strategy: - matrix: - service: - - name: api - ecs_service: monew-api-service - task_definition: monew-api-task - ecr_repo: monew-api - - name: monitor - ecs_service: monew-monitor-service - task_definition: monew-monitor-task - ecr_repo: monew-monitor - - name: prometheus - ecs_service: monew-prometheus-service - task_definition: monew-prometheus-task - ecr_repo: monew-prometheus - - name: grafana - ecs_service: monew-grafana-service - task_definition: monew-grafana-task - ecr_repo: monew-grafana - - steps: - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} - aws-region: ${{ env.AWS_REGION }} - - - name: Login to Amazon ECR - id: login-ecr - uses: aws-actions/amazon-ecr-login@v2 - - # ======================================== - # 태스크 정의 업데이트 - # ======================================== - - name: Update task definition - env: - ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} - IMAGE_TAG: ${{ github.sha }} - run: | - # 현재 태스크 정의 가져오기 - TASK_DEFINITION=$(aws ecs describe-task-definition \ - --task-definition ${{ matrix.service.task_definition }} \ - --query 'taskDefinition' \ - --output json) - - # 새 이미지로 업데이트 - NEW_TASK_DEFINITION=$(echo $TASK_DEFINITION | jq \ - --arg IMAGE "$ECR_REGISTRY/${{ matrix.service.ecr_repo }}:$IMAGE_TAG" \ - '.containerDefinitions[0].image = $IMAGE | - del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)') - - # 새 태스크 정의 등록 - NEW_TASK_ARN=$(aws ecs register-task-definition \ - --cli-input-json "$NEW_TASK_DEFINITION" | \ - jq -r '.taskDefinition.taskDefinitionArn') - - echo "NEW_TASK_ARN=$NEW_TASK_ARN" >> $GITHUB_ENV - - # ======================================== - # 서비스 재시작 - # ======================================== - - name: Update ECS service - run: | - aws ecs update-service \ - --cluster ${{ env.ECS_CLUSTER }} \ - --service ${{ matrix.service.ecs_service }} \ - --task-definition ${{ env.NEW_TASK_ARN }} \ - --desired-count 1 \ - --force-new-deployment - - echo "✅ ${{ matrix.service.name }} service deployed!" + # 푸시 + docker push ${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-${{ github.sha }} + docker push ${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-latest # ======================================== - # 배포 상태 확인 (선택사항) + # 4. ECS 배포 (ap-northeast-2) + # ⚠️ ECS는 실제 리전 사용 # ======================================== - - name: Wait for service stability - run: | - echo "Waiting for service to be stable..." - aws ecs wait services-stable \ - --cluster ${{ env.ECS_CLUSTER }} \ - --services ${{ matrix.service.ecs_service }} - - echo "🚀 ${{ matrix.service.name }} deployment completed!" - - # ======================================== - # ECS 배포 - Batch (API 안정화 후) - # ======================================== - deploy-batch: - runs-on: ubuntu-latest - needs: deploy-main # ⭐ API 배포 완료 후 실행 - strategy: - matrix: - service: - - name: batch - ecs_service: monew-batch-service - task_definition: monew-batch-task - ecr_repo: monew-batch - - steps: - - name: Configure AWS credentials + - name: Configure AWS credentials for ECS uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} - aws-region: ${{ env.AWS_REGION }} - - - name: Login to Amazon ECR - id: login-ecr - uses: aws-actions/amazon-ecr-login@v2 + aws-region: ${{ env.AWS_REGION }} # ⭐ ECS는 ap-northeast-2 - # ======================================== - # 태스크 정의 업데이트 - # ======================================== - name: Update task definition - env: - ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} - IMAGE_TAG: ${{ github.sha }} run: | # 현재 태스크 정의 가져오기 TASK_DEFINITION=$(aws ecs describe-task-definition \ --task-definition ${{ matrix.service.task_definition }} \ - --query 'taskDefinition' \ - --output json) + --query 'taskDefinition') # 새 이미지로 업데이트 NEW_TASK_DEFINITION=$(echo $TASK_DEFINITION | jq \ - --arg IMAGE "$ECR_REGISTRY/${{ matrix.service.ecr_repo }}:$IMAGE_TAG" \ + --arg IMAGE "${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-latest" \ '.containerDefinitions[0].image = $IMAGE | del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)') @@ -221,9 +113,6 @@ jobs: echo "NEW_TASK_ARN=$NEW_TASK_ARN" >> $GITHUB_ENV - # ======================================== - # 서비스 재시작 - # ======================================== - name: Update ECS service run: | aws ecs update-service \ @@ -235,9 +124,6 @@ jobs: echo "✅ ${{ matrix.service.name }} service deployed!" - # ======================================== - # 배포 상태 확인 (선택사항) - # ======================================== - name: Wait for service stability run: | echo "Waiting for service to be stable..." From 58550375d90d2119484d719f72d5c2bf402c0f90 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 6 Nov 2025 11:14:29 +0900 Subject: [PATCH 05/19] =?UTF-8?q?trigger=20CD=20pipeline,=20IAM=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From dff4e9f5e69c23e45f0466c2e0381b38a6804086 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 6 Nov 2025 11:31:12 +0900 Subject: [PATCH 06/19] =?UTF-8?q?fix=20:=20=EA=B6=8C=ED=95=9C=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EB=94=94=EB=B2=84=EA=B9=85=20=EC=9A=A9=20run=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20=ED=81=B4=EB=9D=BC=EC=8A=A4=ED=84=B0=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20monew-cluster-1=20=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 929ad8c..6dfee8e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,7 +8,7 @@ on: env: AWS_REGION: ap-northeast-2 - ECS_CLUSTER: monew-cluster + ECS_CLUSTER: monew-cluster-1 ECR_REPOSITORY: public.ecr.aws/s7e2q2h9/monew jobs: @@ -61,6 +61,9 @@ jobs: aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} aws-region: us-east-1 # ⭐ Public ECR은 us-east-1만 사용 + - name: Debug AWS caller identity + run: aws sts get-caller-identity --region ${{ env.AWS_REGION }} + - name: Login to Public ECR run: | aws ecr-public get-login-password --region us-east-1 | \ @@ -92,6 +95,15 @@ jobs: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} aws-region: ${{ env.AWS_REGION }} # ⭐ ECS는 ap-northeast-2 + - name: Debug AWS caller identity (ECS creds) + run: aws sts get-caller-identity --region ${{ env.AWS_REGION }} + + - name: Dry-run DescribeTaskDefinition (debug) + run: | + aws ecs describe-task-definition \ + --task-definition ${{ matrix.service.task_definition }} \ + --region ${{ env.AWS_REGION }} \ + --query 'taskDefinition.taskDefinitionArn' --output text - name: Update task definition run: | From 36778544fcd994ecc4185b2f09ce0414dbe01176 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 6 Nov 2025 11:39:28 +0900 Subject: [PATCH 07/19] =?UTF-8?q?fix=20:=20=EB=A6=AC=EC=A0=84=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 83 ++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 46 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6dfee8e..d51720f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,12 +2,14 @@ name: CD - Deploy to ECS on: push: - branches: - - release + branches: [ release ] workflow_dispatch: env: - AWS_REGION: ap-northeast-2 + # ⭐ 리전 분리 + ECS_REGION: ap-northeast-2 + ECR_PUBLIC_REGION: us-east-1 + ECS_CLUSTER: monew-cluster-1 ECR_REPOSITORY: public.ecr.aws/s7e2q2h9/monew @@ -44,103 +46,92 @@ jobs: ecs_service: monew-grafana-service steps: - # ======================================== - # 1. 코드 체크아웃 - # ======================================== - name: Checkout code uses: actions/checkout@v4 - # ======================================== - # 2. Public ECR 로그인 (us-east-1) - # ⚠️ 중요: Public ECR은 us-east-1만 사용! - # ======================================== + # ===== Public ECR (us-east-1) ===== - name: Configure AWS credentials for Public ECR uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} - aws-region: us-east-1 # ⭐ Public ECR은 us-east-1만 사용 + aws-region: ${{ env.ECR_PUBLIC_REGION }} - - name: Debug AWS caller identity - run: aws sts get-caller-identity --region ${{ env.AWS_REGION }} + - name: Debug AWS caller identity (ECR creds) + run: aws sts get-caller-identity --region ${{ env.ECR_PUBLIC_REGION }} - name: Login to Public ECR run: | - aws ecr-public get-login-password --region us-east-1 | \ + aws ecr-public get-login-password --region ${{ env.ECR_PUBLIC_REGION }} | \ docker login --username AWS --password-stdin public.ecr.aws - # ======================================== - # 3. Docker 이미지 빌드 & 푸시 - # ======================================== - name: Build and push ${{ matrix.service.name }} image run: | - # 빌드 - docker build \ - -f ${{ matrix.service.dockerfile }} \ + set -euo pipefail + docker build -f ${{ matrix.service.dockerfile }} \ -t ${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-${{ github.sha }} \ - -t ${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-latest \ - . - - # 푸시 + -t ${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-latest . docker push ${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-${{ github.sha }} docker push ${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-latest - # ======================================== - # 4. ECS 배포 (ap-northeast-2) - # ⚠️ ECS는 실제 리전 사용 - # ======================================== + # ===== ECS (ap-northeast-2) ===== - name: Configure AWS credentials for ECS uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} - aws-region: ${{ env.AWS_REGION }} # ⭐ ECS는 ap-northeast-2 + aws-region: ${{ env.ECS_REGION }} + - name: Debug AWS caller identity (ECS creds) - run: aws sts get-caller-identity --region ${{ env.AWS_REGION }} + run: aws sts get-caller-identity --region ${{ env.ECS_REGION }} - name: Dry-run DescribeTaskDefinition (debug) run: | + set -euo pipefail + echo "Family: ${{ matrix.service.task_definition }} Region: ${{ env.ECS_REGION }}" aws ecs describe-task-definition \ --task-definition ${{ matrix.service.task_definition }} \ - --region ${{ env.AWS_REGION }} \ - --query 'taskDefinition.taskDefinitionArn' --output text + --region ${{ env.ECS_REGION }} \ + --query 'taskDefinition.taskDefinitionArn' --output text - name: Update task definition run: | - # 현재 태스크 정의 가져오기 + set -euo pipefail TASK_DEFINITION=$(aws ecs describe-task-definition \ --task-definition ${{ matrix.service.task_definition }} \ - --query 'taskDefinition') + --region ${{ env.ECS_REGION }} \ + --query 'taskDefinition' --output json) - # 새 이미지로 업데이트 - NEW_TASK_DEFINITION=$(echo $TASK_DEFINITION | jq \ + NEW_TASK_DEFINITION=$(echo "$TASK_DEFINITION" | jq \ --arg IMAGE "${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-latest" \ - '.containerDefinitions[0].image = $IMAGE | - del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)') + '.containerDefinitions[0].image = $IMAGE + | del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)') - # 새 태스크 정의 등록 NEW_TASK_ARN=$(aws ecs register-task-definition \ - --cli-input-json "$NEW_TASK_DEFINITION" | \ - jq -r '.taskDefinition.taskDefinitionArn') + --cli-input-json "$NEW_TASK_DEFINITION" \ + --region ${{ env.ECS_REGION }} \ + --query 'taskDefinition.taskDefinitionArn' --output text) echo "NEW_TASK_ARN=$NEW_TASK_ARN" >> $GITHUB_ENV - name: Update ECS service run: | + set -euo pipefail aws ecs update-service \ --cluster ${{ env.ECS_CLUSTER }} \ --service ${{ matrix.service.ecs_service }} \ --task-definition ${{ env.NEW_TASK_ARN }} \ --desired-count 1 \ - --force-new-deployment - + --force-new-deployment \ + --region ${{ env.ECS_REGION }} echo "✅ ${{ matrix.service.name }} service deployed!" - name: Wait for service stability run: | + set -euo pipefail echo "Waiting for service to be stable..." aws ecs wait services-stable \ --cluster ${{ env.ECS_CLUSTER }} \ - --services ${{ matrix.service.ecs_service }} - - echo "🚀 ${{ matrix.service.name }} deployment completed!" \ No newline at end of file + --services ${{ matrix.service.ecs_service }} \ + --region ${{ env.ECS_REGION }} + echo "🚀 ${{ matrix.service.name }} deployment completed!" From 4d2518518a5f6959ee046342ddf26f1be84c91f6 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 6 Nov 2025 14:33:52 +0900 Subject: [PATCH 08/19] =?UTF-8?q?fix=20:=20EFS=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20deploy=EC=97=90=EC=84=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EC=9C=BC=EB=A1=9C=20=EC=B6=94=EC=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 7 +++++-- Dockerfile.prom | 17 ++++++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d51720f..a10f00a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -104,8 +104,11 @@ jobs: NEW_TASK_DEFINITION=$(echo "$TASK_DEFINITION" | jq \ --arg IMAGE "${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-latest" \ - '.containerDefinitions[0].image = $IMAGE - | del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)') + --arg NAME "${{ matrix.service.name }}" ' + .containerDefinitions |= + ( map( if .name == $NAME then (.image = $IMAGE) else . end ) ) + | del(.taskDefinitionArn, .revision, .status, .requiresAttributes, + .compatibilities, .registeredAt, .registeredBy)') NEW_TASK_ARN=$(aws ecs register-task-definition \ --cli-input-json "$NEW_TASK_DEFINITION" \ diff --git a/Dockerfile.prom b/Dockerfile.prom index 51c3ac6..3b501d4 100644 --- a/Dockerfile.prom +++ b/Dockerfile.prom @@ -1,7 +1,18 @@ FROM prom/prometheus:latest -# Custom prometheus.yml 복사 +# 설정 파일 COPY monew-monitor/src/main/resources/prometheus.yml /etc/prometheus/prometheus.yml -# 포트 노출 -EXPOSE 9090 \ No newline at end of file +EXPOSE 9090 + +# 핵심: TSDB 경로를 /prometheus 로! +# (ECS 태스크에서 /prometheus를 EFS로 마운트했으므로 여기에 저장됨) +CMD [ \ + "--config.file=/etc/prometheus/prometheus.yml", \ + "--storage.tsdb.path=/prometheus", \ + "--storage.tsdb.retention.time=30d", \ + "--storage.tsdb.retention.size=10GB", \ + "--web.enable-admin-api", \ + "--web.console.libraries=/usr/share/prometheus/console_libraries", \ + "--web.console.templates=/usr/share/prometheus/consoles" \ +] \ No newline at end of file From 1819fbcaae7f97df8d86191c8494da760efb872a Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 6 Nov 2025 15:27:22 +0900 Subject: [PATCH 09/19] =?UTF-8?q?fix=20:=20=EB=B0=B0=EC=B9=98=20applicatio?= =?UTF-8?q?n-prod.yml=EC=97=90=EC=84=9C=20monew.api.url=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=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 --- monew-batch/src/main/resources/application-prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monew-batch/src/main/resources/application-prod.yml b/monew-batch/src/main/resources/application-prod.yml index f4763f4..7c7b714 100644 --- a/monew-batch/src/main/resources/application-prod.yml +++ b/monew-batch/src/main/resources/application-prod.yml @@ -66,4 +66,4 @@ aws: monew: api: - url: ${http://monew-api.monew.local:8080} \ No newline at end of file + url: ${MONEW_API_URL:http://monew-app-alb-721921608.ap-northeast-2.elb.amazonaws.com/api} \ No newline at end of file From 940af7e3aec7a33279d563530fa05233a5a73740 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 6 Nov 2025 15:52:48 +0900 Subject: [PATCH 10/19] =?UTF-8?q?feat=20:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=BF=90=EB=A7=8C=20?= =?UTF-8?q?=EC=95=84=EB=8B=88=EB=9D=BC=20=EC=A4=91=EC=A7=80,=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=EB=90=98=EC=97=88=EC=9D=84=EB=95=8C=20=EC=9E=AC?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 137 +++++++++++++++++++++++++++++------ 1 file changed, 115 insertions(+), 22 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a10f00a..5a84a38 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -6,13 +6,15 @@ on: workflow_dispatch: env: - # ⭐ 리전 분리 ECS_REGION: ap-northeast-2 ECR_PUBLIC_REGION: us-east-1 ECS_CLUSTER: monew-cluster-1 ECR_REPOSITORY: public.ecr.aws/s7e2q2h9/monew + PUBLIC_SUBNETS_CSV: subnet-00530329356c03add,subnet-008505c5a32cd0093,subnet-08601cd2c0aa46873,subnet-03f28f954846ad79d + ECS_SERVICE_SG: sg-0fbe8feeddc4e8195,sg-08a99e539bed73ed3 + jobs: build-and-deploy: runs-on: ubuntu-latest @@ -24,26 +26,41 @@ jobs: tag: monew-api task_definition: monew-api-task ecs_service: monew-api-service + container: monew-api-app + port: 8080 + target_group_arn: arn:aws:elasticloadbalancing:ap-northeast-2:381437600029:targetgroup/monew-api-target/188870153e8974df - name: batch dockerfile: Dockerfile.batch tag: monew-batch task_definition: monew-batch-task ecs_service: monew-batch-service + container: monew-batch-app + port: 8081 + target_group_arn: arn:aws:elasticloadbalancing:ap-northeast-2:381437600029:targetgroup/monew-batch-target/8c8fd5dc5924e8f6 - name: monitor dockerfile: Dockerfile.monitor tag: monew-monitor task_definition: monew-monitor-task ecs_service: monew-monitor-service + container: monew-monitor-app + port: 8082 + target_group_arn: arn:aws:elasticloadbalancing:ap-northeast-2:381437600029:targetgroup/monew-monitor-target/f2041a48b08d9ef9 - name: prometheus dockerfile: Dockerfile.prom tag: prometheus task_definition: monew-prometheus-task ecs_service: monew-prometheus-service + container: monew-prometheus-app + port: 9090 + target_group_arn: arn:aws:elasticloadbalancing:ap-northeast-2:381437600029:targetgroup/monew-prometheus-target/01ce4f2072a65a1f - name: grafana dockerfile: Dockerfile.grafana tag: grafana task_definition: monew-grafana-task ecs_service: monew-grafana-service + container: monew-grafana-app + port: 3000 + target_group_arn: arn:aws:elasticloadbalancing:ap-northeast-2:381437600029:targetgroup/monew-grafana-target/aece83e522ce15b5 steps: - name: Checkout code @@ -94,45 +111,121 @@ jobs: --region ${{ env.ECS_REGION }} \ --query 'taskDefinition.taskDefinitionArn' --output text - - name: Update task definition + - name: Update task definition (swap image) run: | set -euo pipefail - TASK_DEFINITION=$(aws ecs describe-task-definition \ - --task-definition ${{ matrix.service.task_definition }} \ - --region ${{ env.ECS_REGION }} \ + FAMILY="${{ matrix.service.task_definition }}" + REGION="${{ env.ECS_REGION }}" + CNAME="${{ matrix.service.container }}" + IMAGE="${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-latest" + + TD=$(aws ecs describe-task-definition \ + --task-definition "$FAMILY" \ + --region "$REGION" \ --query 'taskDefinition' --output json) - NEW_TASK_DEFINITION=$(echo "$TASK_DEFINITION" | jq \ - --arg IMAGE "${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-latest" \ - --arg NAME "${{ matrix.service.name }}" ' + echo "== Before =="; echo "$TD" | jq '.containerDefinitions[] | {name,image}' + + NEW_TD=$(echo "$TD" | jq \ + --arg IMAGE "$IMAGE" --arg CNAME "$CNAME" ' .containerDefinitions |= - ( map( if .name == $NAME then (.image = $IMAGE) else . end ) ) + ( map( if .name == $CNAME then (.image = $IMAGE) else . end ) ) | del(.taskDefinitionArn, .revision, .status, .requiresAttributes, - .compatibilities, .registeredAt, .registeredBy)') + .compatibilities, .registeredAt, .registeredBy)') + + echo "== After (preview) =="; echo "$NEW_TD" | jq '.containerDefinitions[] | {name,image}' NEW_TASK_ARN=$(aws ecs register-task-definition \ - --cli-input-json "$NEW_TASK_DEFINITION" \ - --region ${{ env.ECS_REGION }} \ + --cli-input-json "$NEW_TD" \ + --region "$REGION" \ --query 'taskDefinition.taskDefinitionArn' --output text) echo "NEW_TASK_ARN=$NEW_TASK_ARN" >> $GITHUB_ENV + echo "Registered: $NEW_TASK_ARN" - - name: Update ECS service + - name: Create or Update ECS service (auto) run: | set -euo pipefail - aws ecs update-service \ - --cluster ${{ env.ECS_CLUSTER }} \ - --service ${{ matrix.service.ecs_service }} \ - --task-definition ${{ env.NEW_TASK_ARN }} \ - --desired-count 1 \ - --force-new-deployment \ - --region ${{ env.ECS_REGION }} - echo "✅ ${{ matrix.service.name }} service deployed!" + CLUSTER="${{ env.ECS_CLUSTER }}" + SERVICE="${{ matrix.service.ecs_service }}" + REGION="${{ env.ECS_REGION }}" + TG_ARN="${{ matrix.service.target_group_arn }}" + CONTAINER="${{ matrix.service.container }}" + PORT="${{ matrix.service.port }}" + TASK_DEF="${{ env.NEW_TASK_ARN }}" + + # Network JSON (public subnets + monew-ecs-sg, public IP) + IFS=',' read -r -a SUBNETS <<< "${{ env.PUBLIC_SUBNETS_CSV }}" + SUBNET_JSON=$(printf '"%s",' "${SUBNETS[@]}"); SUBNET_JSON="[${SUBNET_JSON%,}]" + NET_JSON=$(jq -nc \ + --argjson subnets "$SUBNET_JSON" \ + --arg sg "${{ env.ECS_SERVICE_SG }}" \ + '{awsvpcConfiguration:{subnets: $subnets, securityGroups: [$sg], assignPublicIp: "ENABLED"}}') + + LB_JSON=$(jq -nc \ + --arg tg "$TG_ARN" --arg cn "$CONTAINER" --argjson cp "$PORT" \ + '[{targetGroupArn:$tg, containerName:$cn, containerPort:$cp}]') + + DESC=$(aws ecs describe-services --cluster "$CLUSTER" --services "$SERVICE" --region "$REGION" --output json || true) + FAIL_LEN=$(echo "$DESC" | jq -r '.failures | length // 0') + SVC_LEN=$(echo "$DESC" | jq -r '.services | length // 0') + STATUS=$(echo "$DESC" | jq -r '.services[0].status // empty') + + if [ "$FAIL_LEN" != "0" ] || [ "$SVC_LEN" = "0" ]; then + echo "🟢 Service not found → create-service" + aws ecs create-service \ + --cluster "$CLUSTER" \ + --service-name "$SERVICE" \ + --task-definition "$TASK_DEF" \ + --desired-count 1 \ + --launch-type FARGATE \ + --platform-version LATEST \ + --deployment-configuration "maximumPercent=200,minimumHealthyPercent=100" \ + --deployment-controller "type=ECS" \ + --enable-execute-command \ + --network-configuration "$NET_JSON" \ + --load-balancers "$LB_JSON" \ + --region "$REGION" + else + if [ "$STATUS" != "ACTIVE" ]; then + echo "🟠 Service exists but status=$STATUS → delete & recreate" + aws ecs delete-service --cluster "$CLUSTER" --service "$SERVICE" --force --region "$REGION" || true + for i in {1..30}; do + sleep 10 + D=$(aws ecs describe-services --cluster "$CLUSTER" --services "$SERVICE" --region "$REGION" --output json || true) + FL=$(echo "$D" | jq -r '.failures | length // 0') + SL=$(echo "$D" | jq -r '.services | length // 0') + [ "$FL" != "0" ] || [ "$SL" = "0" ] && break + echo "Waiting deletion..." + done + aws ecs create-service \ + --cluster "$CLUSTER" \ + --service-name "$SERVICE" \ + --task-definition "$TASK_DEF" \ + --desired-count 1 \ + --launch-type FARGATE \ + --platform-version LATEST \ + --deployment-configuration "maximumPercent=200,minimumHealthyPercent=100" \ + --deployment-controller "type=ECS" \ + --enable-execute-command \ + --network-configuration "$NET_JSON" \ + --load-balancers "$LB_JSON" \ + --region "$REGION" + else + echo "🔵 Service ACTIVE → update-service" + aws ecs update-service \ + --cluster "$CLUSTER" \ + --service "$SERVICE" \ + --task-definition "$TASK_DEF" \ + --desired-count 1 \ + --force-new-deployment \ + --region "$REGION" + fi + fi - name: Wait for service stability run: | set -euo pipefail - echo "Waiting for service to be stable..." aws ecs wait services-stable \ --cluster ${{ env.ECS_CLUSTER }} \ --services ${{ matrix.service.ecs_service }} \ From c9b94985fcfe57c5d01a99a230b50a9aec17d819 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 6 Nov 2025 16:17:38 +0900 Subject: [PATCH 11/19] =?UTF-8?q?feat=20:=20=EA=B8=B0=EC=82=AC=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=20=EC=8A=A4=EC=BC=80=EC=A5=B4=EB=9F=AC=20cron?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD,=20deploy=20=EB=B3=B4?= =?UTF-8?q?=EC=95=88=EA=B7=B8=EB=A3=B9=20=EB=A7=A4=ED=95=91=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 25 +++++++++++++++---- .../scheduler/AricleBatchScheduler.java | 4 +-- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5a84a38..8a98c72 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -154,13 +154,28 @@ jobs: PORT="${{ matrix.service.port }}" TASK_DEF="${{ env.NEW_TASK_ARN }}" - # Network JSON (public subnets + monew-ecs-sg, public IP) + # Network JSON (public subnets + SGs[], public IP) IFS=',' read -r -a SUBNETS <<< "${{ env.PUBLIC_SUBNETS_CSV }}" - SUBNET_JSON=$(printf '"%s",' "${SUBNETS[@]}"); SUBNET_JSON="[${SUBNET_JSON%,}]" + SUBNET_JSON=$(printf '"%s",' "${SUBNETS[@]}") + SUBNET_JSON="[${SUBNET_JSON%,}]" + + SGS_STR="${{ env.ECS_SERVICE_SG }}" # 예: "sg-aaaa,sg-bbbb" + SG_JSON=$(jq -nc --arg s "$SGS_STR" '$s | split(",")') + NET_JSON=$(jq -nc \ - --argjson subnets "$SUBNET_JSON" \ - --arg sg "${{ env.ECS_SERVICE_SG }}" \ - '{awsvpcConfiguration:{subnets: $subnets, securityGroups: [$sg], assignPublicIp: "ENABLED"}}') + --argjson subnets "$SUBNET_JSON" \ + --argjson sgs "$SG_JSON" \ + '{ + awsvpcConfiguration: { + subnets: $subnets, + securityGroups: $sgs, + assignPublicIp: "ENABLED" + } + }') + + echo "Network: $NET_JSON" + + LB_JSON=$(jq -nc \ --arg tg "$TG_ARN" --arg cn "$CONTAINER" --argjson cp "$PORT" \ diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/AricleBatchScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/AricleBatchScheduler.java index 1077cf3..bfc5f3a 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/AricleBatchScheduler.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/AricleBatchScheduler.java @@ -35,8 +35,8 @@ public AricleBatchScheduler( this.yonhapRssJob = yonhapRssJob; } -// @Scheduled(cron = "0 0 * * * *", zone = "Asia/Seoul") - @Scheduled(fixedRate = 600000) // 테스트용 + @Scheduled(cron = "0 0 * * * *", zone = "Asia/Seoul") +// @Scheduled(fixedRate = 600000) // 테스트용 public void runJob() throws Exception { log.info("🕒 [Batch Scheduler] 뉴스 수집 Job 실행"); From a6c219df9c3a950e03341848cd38d6f94035897d Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Tue, 4 Nov 2025 17:17:01 +0900 Subject: [PATCH 12/19] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=ED=95=98=EB=8A=94=EB=8D=B0=20=EC=96=B4?= =?UTF-8?q?=EB=A0=A4=EC=9B=8C=20=EB=A1=9C=EA=B7=B8=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monew_api/interest/service/InterestServiceImpl.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java index 7fd6c91..6c1222d 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java @@ -106,8 +106,6 @@ public CursorPageResponseInterestDto getInterests(Long userId, Slice slices = interestRepository.findAll( keyword, orderBy, direction, cursor, after, limit); - log.info("REQ userId={}, keyword={}, orderBy={}, direction={}, cursor={}, after={}, limit={}", - userId, keyword, orderBy, direction, cursor, after, limit); List interests = slices.getContent(); @@ -126,9 +124,6 @@ public CursorPageResponseInterestDto getInterests(Long userId, boolean subscribedByMe = subscribedIds.contains(interest.getId()); InterestDto dto = interestMapper.toInterestDto(interest, keywords, subscribedByMe, subscriberCount); - - log.info("DBG dto id={}, name={}, subscriberCount={} subscribedByMe={}", - dto.id(), dto.name(), dto.subscriberCount(), dto.subscribedByMe()); interestDtos.add(dto); } From 6398af431b643a53433ca45af960b7b388f68db2 Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Tue, 4 Nov 2025 17:52:27 +0900 Subject: [PATCH 13/19] =?UTF-8?q?feat=20:=20=EA=B4=80=EC=8B=AC=EC=82=AC=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20(=EC=8A=AC=EB=9D=BC=EC=9D=B4=EC=8A=A4=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monew_api/interest/TestInterestForm.java | 25 ++ .../InterestRepositoryCustomTest.java | 146 ++++++++++ .../repository/KeywordRepositoryTest.java | 52 ++++ .../interest/service/InterestServiceTest.java | 249 ++++++++++++++++++ 4 files changed, 472 insertions(+) create mode 100644 monew-api/src/test/java/com/monew/monew_api/interest/TestInterestForm.java create mode 100644 monew-api/src/test/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomTest.java create mode 100644 monew-api/src/test/java/com/monew/monew_api/interest/repository/KeywordRepositoryTest.java create mode 100644 monew-api/src/test/java/com/monew/monew_api/interest/service/InterestServiceTest.java diff --git a/monew-api/src/test/java/com/monew/monew_api/interest/TestInterestForm.java b/monew-api/src/test/java/com/monew/monew_api/interest/TestInterestForm.java new file mode 100644 index 0000000..f642540 --- /dev/null +++ b/monew-api/src/test/java/com/monew/monew_api/interest/TestInterestForm.java @@ -0,0 +1,25 @@ +package com.monew.monew_api.interest; + +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.entity.Keyword; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import org.springframework.test.util.ReflectionTestUtils; + +public class TestInterestForm { + + // interestId Long 생성기 + private static Long generatedId(){ + return new AtomicLong(1).getAndIncrement(); + } + + public static Interest create(String name, List keywords) { + Interest interest = Interest.create(name); + + for (String keyword : keywords) { + interest.addKeyword(new Keyword(keyword)); + } + ReflectionTestUtils.setField(interest, "id", generatedId()); + return interest; + } +} diff --git a/monew-api/src/test/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomTest.java b/monew-api/src/test/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomTest.java new file mode 100644 index 0000000..d84cf96 --- /dev/null +++ b/monew-api/src/test/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomTest.java @@ -0,0 +1,146 @@ +package com.monew.monew_api.interest.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.monew.monew_api.common.config.QuerydslConfig; +import com.monew.monew_api.interest.dto.InterestOrderBy; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.entity.Keyword; +import com.querydsl.core.types.Order; +import java.time.LocalDateTime; +import java.util.Comparator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Slice; +import org.springframework.test.context.ActiveProfiles; + + +@DataJpaTest +@ActiveProfiles("test") +@Import(QuerydslConfig.class) +public class InterestRepositoryCustomTest { + + @Qualifier("interestRepositoryCustomImpl") + @Autowired + InterestRepositoryCustom interestRepositoryCustom; + + @Autowired + TestEntityManager em; + + @BeforeEach + void setUp() { + Interest i1 = Interest.create("interest1"); + Interest i2 = Interest.create("interest2"); + Interest i3 = Interest.create("interest3"); + + // i1: 3명 i2: 2명 i3: 1명 구독 + i1.addSubscriberCount(); + i1.addSubscriberCount(); + i1.addSubscriberCount(); + i2.addSubscriberCount(); + i2.addSubscriberCount(); + i3.addSubscriberCount(); + + em.persist(i1); + em.persist(i2); + em.persist(i3); + + Keyword k1 = new Keyword("keyword1"); + Keyword k2 = new Keyword("keyword2"); + Keyword k3 = new Keyword("keyword3"); + Keyword k4 = new Keyword("keyword4"); + + // i1: k1,k2 i2: k2,k3 i3: k4 + i1.addKeyword(k1); + i1.addKeyword(k2); + i2.addKeyword(k2); + i2.addKeyword(k3); + i3.addKeyword(k4); + + em.persist(k1); + em.persist(k2); + em.persist(k3); + em.persist(k4); + + em.flush(); + em.clear(); + } + + @Test + @DisplayName("관심사 전체 조회 - name ASC") + void testFindAllNameASC() { + String keyword = null; + InterestOrderBy orderBy = InterestOrderBy.name; + Order direction = Order.ASC; + String cursor = null; + LocalDateTime after = null; + int limit = 2; + + Slice result = interestRepositoryCustom.findAll( + keyword, orderBy, direction, cursor, after, limit + ); + + assertThat(result).hasSize(2); + assertThat(result.hasNext()).isTrue(); + assertThat(result.getContent()) + .isSortedAccordingTo(Comparator.comparing(Interest::getName)); + + } + + @Test + @DisplayName("검색어로 관심사 조회 - subscriberCount DESC") + void testFindAllSubscriberCountDESC() { + String keyword = "interest1"; + InterestOrderBy orderBy = InterestOrderBy.subscriberCount; + Order direction = Order.DESC; + int limit = 6; + + Slice result = interestRepositoryCustom.findAll( + keyword, orderBy, direction, null, null, limit + ); + + assertThat(result).isNotEmpty(); + assertThat(result.getContent()) + .allMatch(i -> i.getName().contains("interest1")); + } + + @Test + @DisplayName("커서 조회 확인 - subscriberCount ASC") + void testFindAllSubscriberCountASCWithCursor() { + Slice firstSlice = interestRepositoryCustom.findAll( + null, InterestOrderBy.subscriberCount, Order.ASC, null, null, 2 + ); + assertThat(firstSlice).hasSize(2); + assertThat(firstSlice.hasNext()).isTrue(); + + Interest last = firstSlice.getContent().get(1); + String nextCursor = String.valueOf(last.getSubscriberCount()); + LocalDateTime after = last.getCreatedAt(); + + Slice secondSlice = interestRepositoryCustom.findAll( + null, InterestOrderBy.subscriberCount, Order.DESC, nextCursor, after, 2 + ); + assertThat(secondSlice).isNotEmpty(); + } + + @Test + @DisplayName("관심사 전체 카운트") + void testFindAllSubscriberCount() { + long count = interestRepositoryCustom.countFilteredTotalElements(null); + assertThat(count).isEqualTo(3); + } + + @Test + @DisplayName("검색어로 관심사 카운트") + void testCountFilteredTotalElementsWithKeyword() { + long count = interestRepositoryCustom.countFilteredTotalElements("keyword1"); + assertThat(count).isEqualTo(1); + } + +} diff --git a/monew-api/src/test/java/com/monew/monew_api/interest/repository/KeywordRepositoryTest.java b/monew-api/src/test/java/com/monew/monew_api/interest/repository/KeywordRepositoryTest.java new file mode 100644 index 0000000..17f2c18 --- /dev/null +++ b/monew-api/src/test/java/com/monew/monew_api/interest/repository/KeywordRepositoryTest.java @@ -0,0 +1,52 @@ +package com.monew.monew_api.interest.repository; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +import com.monew.monew_api.common.config.QuerydslConfig; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.entity.Keyword; +import jakarta.persistence.EntityManager; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +@Import(QuerydslConfig.class) +public class KeywordRepositoryTest { + + @Autowired + KeywordRepository keywordRepository; + + @Autowired + InterestRepository interestRepository; + + @Autowired + EntityManager em; + + @DisplayName("관심사 안에 포함되지 않는 키워드 조회") + @Test + public void findOrphanKeywordsIn() { + Keyword keyword1 = keywordRepository.save(new Keyword("keyword1")); + Keyword keyword2 = keywordRepository.save(new Keyword("keyword2")); + Keyword keyword3 = keywordRepository.save(new Keyword("keyword3")); // 고아 키워드 + + Interest interest = Interest.create("interest1"); + interest.addKeyword(keyword1); + interest.addKeyword(keyword2); + interestRepository.saveAndFlush(interest); + + em.flush(); + em.clear(); + + List orphanKeywords = keywordRepository.findOrphanKeywordsIn( + List.of(keyword1, keyword2, keyword3)); + + assertThat(orphanKeywords).hasSize(1); + assertThat(orphanKeywords.get(0).getKeyword()).isEqualTo("keyword3"); + } +} diff --git a/monew-api/src/test/java/com/monew/monew_api/interest/service/InterestServiceTest.java b/monew-api/src/test/java/com/monew/monew_api/interest/service/InterestServiceTest.java new file mode 100644 index 0000000..0e4b35c --- /dev/null +++ b/monew-api/src/test/java/com/monew/monew_api/interest/service/InterestServiceTest.java @@ -0,0 +1,249 @@ +package com.monew.monew_api.interest.service; + +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.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.monew.monew_api.article.repository.ArticleRepository; +import com.monew.monew_api.article.repository.InterestArticleKeywordRepository; +import com.monew.monew_api.article.repository.InterestArticlesRepository; +import com.monew.monew_api.common.exception.interest.InterestDuplicatedException; +import com.monew.monew_api.interest.dto.request.InterestUpdateRequest; +import com.monew.monew_api.user.User; +import com.monew.monew_api.interest.TestInterestForm; +import com.monew.monew_api.interest.dto.InterestOrderBy; +import com.monew.monew_api.interest.dto.request.CursorPageRequestInterestDto; +import com.monew.monew_api.interest.dto.request.InterestRegisterRequest; +import com.monew.monew_api.interest.dto.response.CursorPageResponseInterestDto; +import com.monew.monew_api.interest.dto.response.InterestDto; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_api.interest.mapper.InterestMapper; +import com.monew.monew_api.interest.repository.InterestRepository; +import com.monew.monew_api.interest.repository.KeywordRepository; +import com.monew.monew_api.subscribe.repository.SubscribeRepository; +import com.querydsl.core.types.Order; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.domain.Sort.Direction; + +@ExtendWith(MockitoExtension.class) +public class InterestServiceTest { + + @Mock + InterestRepository interestRepository; + + @Mock + KeywordRepository keywordRepository; + + @Mock + SubscribeRepository subscribeRepository; + + @Mock + ArticleRepository articleRepository; + + @Mock + InterestArticlesRepository interestArticlesRepository; + + @Mock + InterestArticleKeywordRepository interestArticleKeywordRepository; + + @Mock + InterestMapper interestMapper; + + @InjectMocks + InterestServiceImpl interestService; + + @DisplayName("관심사 생성 실패 - 유사도 0.8이상이면 중복 예외") + @Test + void createInterest_fail() { + String newName = "interest1"; + List keywords = List.of("keyword1", "keyword2"); + InterestRegisterRequest request = new InterestRegisterRequest(newName, keywords); + + Interest existing = TestInterestForm.create("interest2", List.of()); + when(interestRepository.findAll()).thenReturn(List.of(existing)); + + assertThatThrownBy(() -> interestService.createInterest(request)) + .isInstanceOf(InterestDuplicatedException.class); + + verify(interestRepository, never()).save(any(Interest.class)); + verify(keywordRepository, never()).save(any(Keyword.class)); + + } + + @DisplayName("관심사 생성 성공 - 유사도 중복 없음") + @Test + void createInterest_success() { + String interestName = "interest1"; + List keywords = List.of("keyword1", "keyword2"); + InterestRegisterRequest request = new InterestRegisterRequest(interestName, keywords); + Interest snapshot = TestInterestForm.create(interestName, keywords); + + // 유사도 검사 통과 + when(interestRepository.findAll()).thenReturn(Collections.emptyList()); + // 키워드 조회 및 저장 + when(keywordRepository.findByKeyword("keyword1")).thenReturn(Optional.empty()); + when(keywordRepository.findByKeyword("keyword2")).thenReturn(Optional.empty()); + when(keywordRepository.save(any(Keyword.class))) + .thenAnswer(invocationOnMock -> { + return invocationOnMock.getArgument(0); + }); + when(interestRepository.save(any(Interest.class))) + .thenReturn(snapshot); + + InterestDto expected = new InterestDto( + null, + interestName, + List.of("keyword1", "keyword2"), + 0L, + false); + + when(interestMapper.toDto(eq(snapshot), anyList(), eq(false))).thenReturn(expected); + + InterestDto result = interestService.createInterest(request); + + assertThat(result.name()).isEqualTo(interestName); + assertThat(result.keywords()).contains("keyword1", "keyword2"); + assertThat(result.subscriberCount()).isEqualTo(0); + + verify(interestRepository).save(any(Interest.class)); + } + + @DisplayName("관심사 목록 조회 - name DESC") + @Test + void getInterests() { + User user = new User("user@test.com", "user", "password"); + Long userId = user.getId(); + + CursorPageRequestInterestDto request = new CursorPageRequestInterestDto( + null, + InterestOrderBy.name, Order.DESC, null, null, 3); + + Interest interest = TestInterestForm.create("interest1", List.of("k1", "k2")); + Slice slices = new SliceImpl<>(List.of(interest), + PageRequest.of(0, 3), false); + + when(interestRepository.findAll(request.keyword(), + request.orderBy(), request.direction(), + request.cursor(), request.after(), + request.limit())).thenReturn(slices); + + when(interestRepository.countFilteredTotalElements(any())).thenReturn(1L); + + CursorPageResponseInterestDto result = interestService.getInterests(userId, request); + + assertThat(result.content()).hasSize(1); + assertThat(result.totalElements()).isEqualTo(1L); + assertThat(result.hasNext()).isEqualTo(false); + verify(interestRepository).findAll(request.keyword(), + request.orderBy(), request.direction(), + request.cursor(), request.after(), + request.limit()); + } + + @DisplayName("관심사 수정 시 키워드 추가/삭제 - 관련 기사 없음") + @Test + void updateInterestKeywords() { + // keyword1 삭제하고 keyword2 추가 + String name = "interest1"; + Interest interest = TestInterestForm.create(name, List.of("keyword1")); + InterestUpdateRequest request = new InterestUpdateRequest(List.of("keyword2")); + + when(interestRepository.findById(any(Long.class))) + .thenReturn(Optional.of(interest)); + + when(keywordRepository.findAllByKeywordIn(argThat(list -> + list.size() == 1 && list.contains("keyword2") + ))).thenReturn(List.of()); + + when(keywordRepository.save(any(Keyword.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + when(interestArticleKeywordRepository.findArticleIdsByKeywordIds(anyList())) + .thenReturn(Collections.emptyList()); + + // 고아 키워드 삭제: keyword1 + when(keywordRepository.findOrphanKeywordsIn(anyList())) + .thenReturn(List.of(new Keyword("keyword1"))); + + when(interestMapper.toDto(eq(interest), anyList(), eq(false))) + .thenAnswer(inv -> { + Interest it = inv.getArgument(0); + @SuppressWarnings("unchecked") + List kws = inv.getArgument(1); + return new InterestDto(it.getId(), it.getName(), kws, (long) it.getSubscriberCount(), false); + }); + + InterestDto result = interestService.updateInterestKeywords(request, interest.getId()); + + assertThat(result.keywords()).containsExactly("keyword2"); + verify(keywordRepository).save(any(Keyword.class)); + verify(keywordRepository).deleteAll(any()); + verify(interestArticleKeywordRepository).findArticleIdsByKeywordIds(anyList()); + verify(interestArticleKeywordRepository, never()).findArticlesUsedElsewhere(anyList(), anyList(), anyLong()); + verify(articleRepository, never()).markAsDeleted(anyList()); + } + + @DisplayName("관심사 삭제- 관련 기사 없으면 바로 삭제") + @Test + void deleteInterest_noArticles() { + Interest interest = TestInterestForm.create("interest1", List.of("k1", "k2")); + Long interestId = interest.getId(); + + when(interestRepository.findById(any(Long.class))).thenReturn(Optional.of(interest)); + when(interestArticlesRepository.findArticleIdsByInterestId(interestId)) + .thenReturn(List.of()); + + interestService.deleteInterest(interestId); + + verify(interestRepository).delete(interest); + } + + @DisplayName("관심사 삭제 - 일부 기사만 다른 관심사에서 사용 중이면 나머지만 논리 삭제 후 바로 삭제") + @Test + void deleteInterest_someUsedElsewhere() { + Interest interest = TestInterestForm.create("interest1", List.of("k1", "k2")); + Long interestId = interest.getId(); + + when(interestRepository.findById(interestId)).thenReturn(Optional.of(interest)); + + // 연결된 기사[1,2,3] 중 [2]는 다른 관심사에서도 사용됨 + List articleIds = List.of(1L, 2L, 3L); + when(interestArticlesRepository.findArticleIdsByInterestId(interestId)) + .thenReturn(articleIds); + when(interestArticlesRepository.findArticleIdsUsedByOtherInterests(articleIds, interestId)) + .thenReturn(List.of(2L)); + + interestService.deleteInterest(interestId); + + // 논리 삭제 대상: [1,3] + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(articleRepository).markAsDeleted(captor.capture()); + assertThat(captor.getValue()).containsExactlyInAnyOrder(1L, 3L); + + verify(interestRepository).delete(interest); + } +} From 65ed7365e96c27a1b6a999e47af50ae895e026cd Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Thu, 6 Nov 2025 16:39:23 +0900 Subject: [PATCH 14/19] =?UTF-8?q?refactor=20:=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interest/service/InterestServiceTest.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/monew-api/src/test/java/com/monew/monew_api/interest/service/InterestServiceTest.java b/monew-api/src/test/java/com/monew/monew_api/interest/service/InterestServiceTest.java index 0e4b35c..9b69ab2 100644 --- a/monew-api/src/test/java/com/monew/monew_api/interest/service/InterestServiceTest.java +++ b/monew-api/src/test/java/com/monew/monew_api/interest/service/InterestServiceTest.java @@ -10,7 +10,9 @@ import static org.mockito.Mockito.atLeast; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; import com.monew.monew_api.article.repository.ArticleRepository; @@ -18,6 +20,8 @@ import com.monew.monew_api.article.repository.InterestArticlesRepository; import com.monew.monew_api.common.exception.interest.InterestDuplicatedException; import com.monew.monew_api.interest.dto.request.InterestUpdateRequest; +import com.monew.monew_api.interest.event.InterestDeletedEvent; +import com.monew.monew_api.interest.event.InterestUpdatedEvent; import com.monew.monew_api.user.User; import com.monew.monew_api.interest.TestInterestForm; import com.monew.monew_api.interest.dto.InterestOrderBy; @@ -43,6 +47,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; import org.springframework.data.domain.SliceImpl; @@ -72,6 +77,9 @@ public class InterestServiceTest { @Mock InterestMapper interestMapper; + @Mock + private ApplicationEventPublisher eventPublisher; + @InjectMocks InterestServiceImpl interestService; @@ -204,6 +212,17 @@ void updateInterestKeywords() { verify(interestArticleKeywordRepository).findArticleIdsByKeywordIds(anyList()); verify(interestArticleKeywordRepository, never()).findArticlesUsedElsewhere(anyList(), anyList(), anyLong()); verify(articleRepository, never()).markAsDeleted(anyList()); + + // 이벤트 검증 + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Object.class); + verify(eventPublisher, times(1)).publishEvent(eventCaptor.capture()); + + Object published = eventCaptor.getValue(); + assertThat(published).isInstanceOf(InterestUpdatedEvent.class); + + InterestUpdatedEvent ev = (InterestUpdatedEvent) published; + assertThat(ev.interestId()).isEqualTo(interest.getId()); + assertThat(ev.newKeywords()).containsExactly("keyword2"); } @DisplayName("관심사 삭제- 관련 기사 없으면 바로 삭제") @@ -245,5 +264,22 @@ void deleteInterest_someUsedElsewhere() { assertThat(captor.getValue()).containsExactlyInAnyOrder(1L, 3L); verify(interestRepository).delete(interest); + + // 이벤트 검증 + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Object.class); + verify(eventPublisher, times(1)).publishEvent(eventCaptor.capture()); + Object published = eventCaptor.getValue(); + assertThat(published).isInstanceOf(InterestDeletedEvent.class); + InterestDeletedEvent ev = (InterestDeletedEvent) published; + assertThat(ev.interestId()).isEqualTo(interestId); + + // then 4) 연관 리포지토리 호출 인자 검증(의도 확인) + verify(interestArticlesRepository).findArticleIdsByInterestId(interestId); + verify(interestArticlesRepository) + .findArticleIdsUsedByOtherInterests(articleIds, interestId); + + // then 5) 불필요한 호출이 없는지(선택적 강화) + verify(interestRepository, never()).deleteById(anyLong()); + verifyNoMoreInteractions(articleRepository, eventPublisher); } } From 5d811301991c8a98db8a44f104fc012074962f00 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 6 Nov 2025 17:05:21 +0900 Subject: [PATCH 15/19] =?UTF-8?q?feat=20:=20=EB=89=B4=EC=8A=A4=20=EB=B0=B1?= =?UTF-8?q?=EC=97=85=20=EC=8A=A4=EC=BC=80=EC=A5=B4=EB=9F=AC=20cron?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monew_batch/article/scheduler/AricleBackupScheduler.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/AricleBackupScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/AricleBackupScheduler.java index 1dfe588..dcbcd4c 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/AricleBackupScheduler.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/AricleBackupScheduler.java @@ -19,8 +19,8 @@ public class AricleBackupScheduler { private final AricleBackupService aricleBackupService; -// @Scheduled(cron = "0 20 4 * * *", zone = "Asia/Seoul") - @Scheduled(fixedRate = 600000) // 테스트용 + @Scheduled(cron = "0 20 4 * * *", zone = "Asia/Seoul") +// @Scheduled(fixedRate = 600000) // 테스트용 public void backupNews() { log.info("🗄 뉴스 백업 시작"); aricleBackupService.backupAllArticles(); From b8a07ab97e0fdcbec82e506ed98bfde8a4aea1f2 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 6 Nov 2025 17:30:47 +0900 Subject: [PATCH 16/19] =?UTF-8?q?feat=20:=20=ED=94=84=EB=A1=9C=EB=A9=94?= =?UTF-8?q?=ED=85=8C=EC=9A=B0=EC=8A=A4=20=EC=99=84=EC=A0=84=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=EC=9D=B4=ED=9B=84=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=8B=9C=EC=9E=91=EC=9C=BC=EB=A1=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B0=94=EA=BF=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존에 락이 걸리던게 헬스체크 0만 가지고는 해결이 안되어서 완전 종료 이후 재시작 로직으로 변경 --- .github/workflows/deploy.yml | 88 +++++++++++++++++++++++------------- 1 file changed, 56 insertions(+), 32 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8a98c72..e218d7a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -154,28 +154,15 @@ jobs: PORT="${{ matrix.service.port }}" TASK_DEF="${{ env.NEW_TASK_ARN }}" - # Network JSON (public subnets + SGs[], public IP) - IFS=',' read -r -a SUBNETS <<< "${{ env.PUBLIC_SUBNETS_CSV }}" - SUBNET_JSON=$(printf '"%s",' "${SUBNETS[@]}") - SUBNET_JSON="[${SUBNET_JSON%,}]" + # --- Network (CLI shorthand: 배열 안전 전달) --- + IFS=',' read -r -a SUBNETS_ARR <<< "${{ env.PUBLIC_SUBNETS_CSV }}" + SUBNETS_SH=$(printf '%s,' "${SUBNETS_ARR[@]}"); SUBNETS_SH="[${SUBNETS_SH%,}]" - SGS_STR="${{ env.ECS_SERVICE_SG }}" # 예: "sg-aaaa,sg-bbbb" - SG_JSON=$(jq -nc --arg s "$SGS_STR" '$s | split(",")') + IFS=',' read -r -a SGS_ARR <<< "${{ env.ECS_SERVICE_SG }}" + SGS_SH=$(printf '%s,' "${SGS_ARR[@]}"); SGS_SH="[${SGS_SH%,}]" - NET_JSON=$(jq -nc \ - --argjson subnets "$SUBNET_JSON" \ - --argjson sgs "$SG_JSON" \ - '{ - awsvpcConfiguration: { - subnets: $subnets, - securityGroups: $sgs, - assignPublicIp: "ENABLED" - } - }') - - echo "Network: $NET_JSON" - - + NET_SH="awsvpcConfiguration={subnets=${SUBNETS_SH},securityGroups=${SGS_SH},assignPublicIp=ENABLED}" + echo "Network(shorthand): $NET_SH" LB_JSON=$(jq -nc \ --arg tg "$TG_ARN" --arg cn "$CONTAINER" --argjson cp "$PORT" \ @@ -186,6 +173,13 @@ jobs: SVC_LEN=$(echo "$DESC" | jq -r '.services | length // 0') STATUS=$(echo "$DESC" | jq -r '.services[0].status // empty') + # 배포 정책: Prometheus만 겹침 금지(0/100), 나머지는 기본(100/200) + if [ "$SERVICE" = "monew-prometheus-service" ]; then + DEPLOY_CONF='maximumPercent=100,minimumHealthyPercent=0' + else + DEPLOY_CONF='maximumPercent=200,minimumHealthyPercent=100' + fi + if [ "$FAIL_LEN" != "0" ] || [ "$SVC_LEN" = "0" ]; then echo "🟢 Service not found → create-service" aws ecs create-service \ @@ -195,10 +189,10 @@ jobs: --desired-count 1 \ --launch-type FARGATE \ --platform-version LATEST \ - --deployment-configuration "maximumPercent=200,minimumHealthyPercent=100" \ + --deployment-configuration "$DEPLOY_CONF" \ --deployment-controller "type=ECS" \ --enable-execute-command \ - --network-configuration "$NET_JSON" \ + --network-configuration "$NET_SH" \ --load-balancers "$LB_JSON" \ --region "$REGION" else @@ -220,21 +214,51 @@ jobs: --desired-count 1 \ --launch-type FARGATE \ --platform-version LATEST \ - --deployment-configuration "maximumPercent=200,minimumHealthyPercent=100" \ + --deployment-configuration "$DEPLOY_CONF" \ --deployment-controller "type=ECS" \ --enable-execute-command \ - --network-configuration "$NET_JSON" \ + --network-configuration "$NET_SH" \ --load-balancers "$LB_JSON" \ --region "$REGION" else - echo "🔵 Service ACTIVE → update-service" - aws ecs update-service \ - --cluster "$CLUSTER" \ - --service "$SERVICE" \ - --task-definition "$TASK_DEF" \ - --desired-count 1 \ - --force-new-deployment \ - --region "$REGION" + echo "🔵 Service ACTIVE" + if [ "$SERVICE" = "monew-prometheus-service" ]; then + echo "🧯 Prometheus: scale down to 0 first (avoid TSDB lock)" + aws ecs update-service \ + --cluster "$CLUSTER" \ + --service "$SERVICE" \ + --desired-count 0 \ + --region "$REGION" + + # 모든 태스크 정지 대기 + for i in {1..60}; do + TASKS=$(aws ecs list-tasks --cluster "$CLUSTER" --service-name "$SERVICE" --region "$REGION" --query 'taskArns' --output json) + if [ "$TASKS" = "[]" ]; then + echo "All prometheus tasks stopped." + break + fi + echo "Waiting prometheus tasks to stop..." + sleep 5 + done + + echo "🔁 Update task def & scale up to 1" + aws ecs update-service \ + --cluster "$CLUSTER" \ + --service "$SERVICE" \ + --task-definition "$TASK_DEF" \ + --desired-count 1 \ + --force-new-deployment \ + --region "$REGION" + else + echo "🔁 Regular rolling update" + aws ecs update-service \ + --cluster "$CLUSTER" \ + --service "$SERVICE" \ + --task-definition "$TASK_DEF" \ + --desired-count 1 \ + --force-new-deployment \ + --region "$REGION" + fi fi fi From 2c4ecbf3b43a0617e07ddae016ad3f3b0bb2c26d Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 6 Nov 2025 23:20:36 +0900 Subject: [PATCH 17/19] =?UTF-8?q?feat=20:=20=EC=9C=A0=EC=A0=80=20=ED=99=9C?= =?UTF-8?q?=EB=8F=99=20=EC=84=B1=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9A=A9=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserActivityPerfController.java | 46 +++++++++++++++++++ .../src/main/resources/application-prod.yml | 2 +- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityPerfController.java diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityPerfController.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityPerfController.java new file mode 100644 index 0000000..6037860 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityPerfController.java @@ -0,0 +1,46 @@ +package com.monew.monew_api.useractivity.controller; + +import com.monew.monew_api.useractivity.dto.UserActivityDto; +import com.monew.monew_api.useractivity.service.UserActivityCacheService; +import com.monew.monew_api.useractivity.service.UserActivityService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@Slf4j +public class UserActivityPerfController { + + private final UserActivityCacheService cacheService; + private final UserActivityService userActivityService; + + @GetMapping("/test/user-activity/{userId}") + public UserActivityDto testUserActivity(@PathVariable String userId, + @RequestParam(defaultValue = "cache") String mode) { + // mode 값: cache | single | multi + long start = System.currentTimeMillis(); + + try { + switch (mode) { + case "cache": + return cacheService.getUserActivityWithCache(userId); + + case "single": + return userActivityService.getUserActivitySingleQuery(userId); + + case "multi": + return userActivityService.getUserActivity(userId); + + default: + throw new IllegalArgumentException("mode 파라미터는 cache | single | multi 중 하나여야 합니다."); + } + } finally { + long elapsed = System.currentTimeMillis() - start; + log.info("[PERF] mode={} took={} ms", mode, elapsed); + } + } +} diff --git a/monew-batch/src/main/resources/application-prod.yml b/monew-batch/src/main/resources/application-prod.yml index 7c7b714..f51b6c1 100644 --- a/monew-batch/src/main/resources/application-prod.yml +++ b/monew-batch/src/main/resources/application-prod.yml @@ -66,4 +66,4 @@ aws: monew: api: - url: ${MONEW_API_URL:http://monew-app-alb-721921608.ap-northeast-2.elb.amazonaws.com/api} \ No newline at end of file + url: ${MONEW_API_URL:http://monew-alb-721921608.ap-northeast-2.elb.amazonaws.com} \ No newline at end of file From 4e6fc5039a23eb54b7841ba305cdedc56d1a743e Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 6 Nov 2025 23:42:58 +0900 Subject: [PATCH 18/19] =?UTF-8?q?fix=20:=20=EC=9C=A0=EC=A0=80=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20/api=20=EB=88=84=EB=9D=BD=EB=90=9C=EA=B2=83=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../useractivity/controller/UserActivityPerfController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityPerfController.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityPerfController.java index 6037860..9912e2f 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityPerfController.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityPerfController.java @@ -18,7 +18,7 @@ public class UserActivityPerfController { private final UserActivityCacheService cacheService; private final UserActivityService userActivityService; - @GetMapping("/test/user-activity/{userId}") + @GetMapping("/api/test/user-activity/{userId}") public UserActivityDto testUserActivity(@PathVariable String userId, @RequestParam(defaultValue = "cache") String mode) { // mode 값: cache | single | multi From 4120a542762466614e6f2a3c45746d48a05cad8b Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Fri, 7 Nov 2025 01:08:34 +0900 Subject: [PATCH 19/19] =?UTF-8?q?feat=20:=20add=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=EA=B8=B0=EC=82=AC=20=EC=A0=9C=EB=AA=A9=EC=97=90=20`"`=20?= =?UTF-8?q?=EA=B0=80=20=EC=9E=88=EC=9C=BC=EB=A9=B4=20"=EB=A1=9C=20html?= =?UTF-8?q?=20=EC=9D=B8=EC=BD=94=EB=94=A9=20=EB=AC=B8=EC=9E=90=EB=A1=9C=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20Map?= =?UTF-8?q?per=EC=97=90=EC=84=9C=20=ED=8C=8C=EC=8B=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/ArticleViewActivityDto.java | 6 +- .../useractivity/dto/CommentActivityDto.java | 6 +- .../dto/CommentLikeActivityDto.java | 6 +- .../mapper/UserActivityRawMapper.java | 55 ++++++++++++++++--- 4 files changed, 54 insertions(+), 19 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/ArticleViewActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/ArticleViewActivityDto.java index 93ee743..d1cc74f 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/ArticleViewActivityDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/ArticleViewActivityDto.java @@ -2,14 +2,12 @@ import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import java.time.LocalDateTime; @Getter +@Setter @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentActivityDto.java index f713a95..559ec90 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentActivityDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentActivityDto.java @@ -2,14 +2,12 @@ import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import java.time.LocalDateTime; @Getter +@Setter @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java index 068cf1c..abf4ee9 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java @@ -2,14 +2,12 @@ import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import java.time.LocalDateTime; @Getter +@Setter @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityRawMapper.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityRawMapper.java index 25626d8..be5d5b5 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityRawMapper.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityRawMapper.java @@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.springframework.web.util.HtmlUtils; import java.util.Collections; import java.util.List; @@ -19,15 +20,12 @@ public class UserActivityRawMapper { private final ObjectMapper objectMapper; - /** - * UserActivityRaw (Record) → UserActivityDto 변환 - */ public UserActivityDto toDto(UserActivityRaw record) { if (record == null) { return null; } - return UserActivityDto.builder() + UserActivityDto dto = UserActivityDto.builder() .id(String.valueOf(record.id())) .email(record.email()) .nickname(record.nickname()) @@ -49,11 +47,13 @@ public UserActivityDto toDto(UserActivityRaw record) { new TypeReference>() {} )) .build(); + + // HTML 엔티티 디코딩 + decodeHtmlEntities(dto); + + return dto; } - /** - * JSON String → List 파싱 - */ private List parseJsonList(String json, TypeReference> typeRef) { if (json == null || json.isBlank() || "[]".equals(json.trim())) { return Collections.emptyList(); @@ -67,4 +67,45 @@ private List parseJsonList(String json, TypeReference> typeRef) { return Collections.emptyList(); } } + + /** + * HTML 엔티티 디코딩 (" → " 등) + */ + private void decodeHtmlEntities(UserActivityDto dto) { + // ArticleViews + if (dto.getArticleViews() != null) { + dto.getArticleViews().forEach(av -> { + if (av.getArticleTitle() != null) { + av.setArticleTitle(HtmlUtils.htmlUnescape(av.getArticleTitle())); + } + if (av.getArticleSummary() != null) { + av.setArticleSummary(HtmlUtils.htmlUnescape(av.getArticleSummary())); + } + }); + } + + // Comments + if (dto.getComments() != null) { + dto.getComments().forEach(c -> { + if (c.getContent() != null) { + c.setContent(HtmlUtils.htmlUnescape(c.getContent())); + } + if (c.getArticleTitle() != null) { + c.setArticleTitle(HtmlUtils.htmlUnescape(c.getArticleTitle())); + } + }); + } + + // CommentLikes + if (dto.getCommentLikes() != null) { + dto.getCommentLikes().forEach(cl -> { + if (cl.getArticleTitle() != null) { + cl.setArticleTitle(HtmlUtils.htmlUnescape(cl.getArticleTitle())); + } + if (cl.getCommentContent() != null) { + cl.setCommentContent(HtmlUtils.htmlUnescape(cl.getCommentContent())); + } + }); + } + } } \ No newline at end of file