diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..e218d7a --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,272 @@ +name: CD - Deploy to ECS + +on: + push: + branches: [ release ] + 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 + strategy: + matrix: + service: + - name: api + dockerfile: Dockerfile.api + 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 + uses: actions/checkout@v4 + + # ===== 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: ${{ env.ECR_PUBLIC_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 ${{ env.ECR_PUBLIC_REGION }} | \ + docker login --username AWS --password-stdin public.ecr.aws + + - name: Build and push ${{ matrix.service.name }} image + run: | + 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 . + docker push ${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-${{ github.sha }} + docker push ${{ env.ECR_REPOSITORY }}:${{ matrix.service.tag }}-latest + + # ===== 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.ECS_REGION }} + + - name: Debug AWS caller identity (ECS creds) + 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.ECS_REGION }} \ + --query 'taskDefinition.taskDefinitionArn' --output text + + - name: Update task definition (swap image) + run: | + set -euo pipefail + 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) + + echo "== Before =="; echo "$TD" | jq '.containerDefinitions[] | {name,image}' + + NEW_TD=$(echo "$TD" | jq \ + --arg IMAGE "$IMAGE" --arg CNAME "$CNAME" ' + .containerDefinitions |= + ( map( if .name == $CNAME then (.image = $IMAGE) else . end ) ) + | del(.taskDefinitionArn, .revision, .status, .requiresAttributes, + .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_TD" \ + --region "$REGION" \ + --query 'taskDefinition.taskDefinitionArn' --output text) + + echo "NEW_TASK_ARN=$NEW_TASK_ARN" >> $GITHUB_ENV + echo "Registered: $NEW_TASK_ARN" + + - name: Create or Update ECS service (auto) + run: | + set -euo pipefail + 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 (CLI shorthand: 배열 안전 전달) --- + IFS=',' read -r -a SUBNETS_ARR <<< "${{ env.PUBLIC_SUBNETS_CSV }}" + SUBNETS_SH=$(printf '%s,' "${SUBNETS_ARR[@]}"); SUBNETS_SH="[${SUBNETS_SH%,}]" + + IFS=',' read -r -a SGS_ARR <<< "${{ env.ECS_SERVICE_SG }}" + SGS_SH=$(printf '%s,' "${SGS_ARR[@]}"); SGS_SH="[${SGS_SH%,}]" + + 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" \ + '[{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') + + # 배포 정책: 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 \ + --cluster "$CLUSTER" \ + --service-name "$SERVICE" \ + --task-definition "$TASK_DEF" \ + --desired-count 1 \ + --launch-type FARGATE \ + --platform-version LATEST \ + --deployment-configuration "$DEPLOY_CONF" \ + --deployment-controller "type=ECS" \ + --enable-execute-command \ + --network-configuration "$NET_SH" \ + --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 "$DEPLOY_CONF" \ + --deployment-controller "type=ECS" \ + --enable-execute-command \ + --network-configuration "$NET_SH" \ + --load-balancers "$LB_JSON" \ + --region "$REGION" + else + 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 + + - name: Wait for service stability + run: | + set -euo pipefail + aws ecs wait services-stable \ + --cluster ${{ env.ECS_CLUSTER }} \ + --services ${{ matrix.service.ecs_service }} \ + --region ${{ env.ECS_REGION }} + echo "🚀 ${{ matrix.service.name }} deployment completed!" diff --git a/Dockerfile.grafana b/Dockerfile.grafana new file mode 100644 index 0000000..0671b26 --- /dev/null +++ b/Dockerfile.grafana @@ -0,0 +1,8 @@ +FROM grafana/grafana:latest + +# 환경변수 설정 +ENV GF_SECURITY_ADMIN_USER=admin \ + GF_SECURITY_ADMIN_PASSWORD=admin + +# 포트 노출 +EXPOSE 3000 \ No newline at end of file diff --git a/Dockerfile.prom b/Dockerfile.prom new file mode 100644 index 0000000..3b501d4 --- /dev/null +++ b/Dockerfile.prom @@ -0,0 +1,18 @@ +FROM prom/prometheus:latest + +# 설정 파일 +COPY monew-monitor/src/main/resources/prometheus.yml /etc/prometheus/prometheus.yml + +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 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/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); } 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..9912e2f --- /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("/api/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-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 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..9b69ab2 --- /dev/null +++ b/monew-api/src/test/java/com/monew/monew_api/interest/service/InterestServiceTest.java @@ -0,0 +1,285 @@ +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.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; +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.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; +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.context.ApplicationEventPublisher; +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; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @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()); + + // 이벤트 검증 + 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("관심사 삭제- 관련 기사 없으면 바로 삭제") + @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); + + // 이벤트 검증 + 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); + } +} diff --git a/monew-batch/build.gradle b/monew-batch/build.gradle index 7700994..17a9110 100644 --- a/monew-batch/build.gradle +++ b/monew-batch/build.gradle @@ -26,4 +26,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/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(); 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 실행"); diff --git a/monew-batch/src/main/resources/application-prod.yml b/monew-batch/src/main/resources/application-prod.yml index e39c3ae..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} # 배포 후 추가 필요 \ 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