diff --git a/.env.example b/.env.example
index 8d7d5ba..00533c5 100644
--- a/.env.example
+++ b/.env.example
@@ -5,6 +5,7 @@ SPRING_PROFILES_ACTIVE=
DB_URL=
DB_USERNAME=
DB_PASSWORD=
+MongoDB_URI=
# AWS
AWS_ACCESS_KEY=
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..3a159f0
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,45 @@
+name: Run Tests and Upload Coverage
+
+on:
+ push:
+ branches: [ main, develop ]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ env:
+ SPRING_PROFILES_ACTIVE: test
+ AWS_S3_ACCESS_KEY: ${{ secrets.AWS_S3_ACCESS_KEY }}
+ AWS_S3_SECRET_KEY: ${{ secrets.AWS_S3_SECRET_KEY }}
+ AWS_S3_REGION: ${{ secrets.AWS_S3_REGION }}
+ AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
+ NAVER_CLIENT_ID: ${{ secrets.NAVER_CLIENT_ID }}
+ NAVER_CLIENT_SECRET: ${{ secrets.NAVER_CLIENT_SECRET }}
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Run Tests and Coverage
+ run: ./gradlew clean test jacocoTestReport
+
+ - name: Upload to Codecov
+ uses: codecov/codecov-action@v4
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ files: |
+ monew-api/build/reports/jacoco/test/jacocoTestReport.xml
+ monew-batch/build/reports/jacoco/test/jacocoTestReport.xml
+ fail_ci_if_error: true
\ No newline at end of file
diff --git a/Dockerfile.api b/Dockerfile.api
new file mode 100644
index 0000000..9e959d1
--- /dev/null
+++ b/Dockerfile.api
@@ -0,0 +1,107 @@
+# ====================================
+# Dockerfile for API Module
+# ====================================
+
+# Stage 1: Build
+FROM gradle:8.5-jdk17-alpine AS builder
+
+# 빌드 인자 선언 (docker-compose.prod.yml에서 전달)
+ARG BUILD_DATE
+ARG VCS_REF
+ARG VERSION
+
+WORKDIR /app
+
+# 루트 프로젝트 설정 파일 복사
+#COPY settings.gradle ./
+#COPY build.gradle ./
+#COPY gradlew ./
+#COPY gradle gradle/
+
+COPY settings.gradle ./
+COPY build.gradle ./
+COPY gradlew ./
+COPY gradle gradle/
+COPY monew-monitor/build.gradle monew-monitor/
+
+# 2) CRLF 제거 + 실행권한 + 존재 확인
+RUN sed -i 's/\r$//' gradlew && chmod +x gradlew && \
+ ls -al && ls -al gradle/wrapper && head -n1 gradlew && \
+ ./gradlew --version
+
+# 모든 서브모듈의 build.gradle 복사 (의존성 캐싱)
+COPY monew-api/build.gradle monew-api/build.gradle
+COPY monew-batch/build.gradle monew-batch/build.gradle
+COPY monew-monitor/build.gradle monew-monitor/build.gradle
+
+# Gradle wrapper 실행 권한
+RUN chmod +x gradlew || true
+
+# 의존성 다운로드 (캐시 레이어)
+RUN ./gradlew dependencies --no-daemon || true
+
+# API 모듈 소스 코드 복사
+COPY monew-api/ monew-api/
+
+# API 모듈만 빌드 (테스트 제외)
+RUN ./gradlew :monew-api:bootJar -x test --no-daemon
+
+# JAR 파일 위치 확인 및 복사
+RUN mkdir -p /app/build && \
+ find monew-api/build/libs -name "*.jar" -not -name "*-plain.jar" -exec cp {} /app/build/app.jar \;
+
+# Stage 2: Runtime
+FROM eclipse-temurin:17-jre-alpine
+
+# 빌드 인자 다시 선언 (runtime stage에서 사용)
+ARG BUILD_DATE
+ARG VCS_REF
+ARG VERSION
+
+WORKDIR /app
+
+# 이미지 메타데이터 레이블 추가
+LABEL org.opencontainers.image.created="${BUILD_DATE}" \
+ org.opencontainers.image.revision="${VCS_REF}" \
+ org.opencontainers.image.version="${VERSION}" \
+ org.opencontainers.image.title="Monew API" \
+ org.opencontainers.image.description="Monew API Server"
+
+# 보안을 위한 non-root 사용자 생성
+RUN addgroup -S spring && adduser -S spring -G spring
+
+# 타임존 설정
+RUN apk add --no-cache tzdata wget && \
+ cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \
+ echo "Asia/Seoul" > /etc/timezone && \
+ apk del tzdata
+
+# 빌드 스테이지에서 JAR 파일 복사
+COPY --from=builder /app/build/app.jar app.jar
+
+# 로그 디렉토리 생성 및 권한 설정
+RUN mkdir -p /app/.logs && chown -R spring:spring /app/.logs
+
+# 소유권 변경
+RUN chown spring:spring app.jar
+
+# 사용자 변경
+USER spring:spring
+
+# 헬스체크 설정
+HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1
+
+# JVM 옵션 설정 (환경변수로 오버라이드 가능)
+ENV JAVA_OPTS="-XX:+UseContainerSupport \
+ -XX:MaxRAMPercentage=75.0 \
+ -XX:InitialRAMPercentage=50.0 \
+ -XX:+UseG1GC \
+ -XX:+OptimizeStringConcat \
+ -Djava.security.egd=file:/dev/./urandom"
+
+# 애플리케이션 포트
+EXPOSE 8080
+
+# 애플리케이션 실행
+ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
\ No newline at end of file
diff --git a/Dockerfile.batch b/Dockerfile.batch
new file mode 100644
index 0000000..9184668
--- /dev/null
+++ b/Dockerfile.batch
@@ -0,0 +1,87 @@
+# ====================================
+# Dockerfile for Batch Module
+# ====================================
+
+# Stage 1: Build
+FROM gradle:8.5-jdk17-alpine AS builder
+
+WORKDIR /app
+
+# Gradle wrapper 및 설정 파일 복사
+#COPY monew-batch/build.gradle settings.gradle ./
+#COPY build.gradle ./
+#COPY gradlew* ./
+#COPY gradle gradle/
+
+COPY settings.gradle ./
+COPY build.gradle ./
+COPY gradlew ./
+COPY gradle gradle/
+
+# 2) CRLF 제거 + 실행권한 + 래퍼 존재/버전 확인
+RUN sed -i 's/\r$//' gradlew && chmod +x gradlew && \
+ ls -al && ls -al gradle/wrapper && head -n1 gradlew && \
+ ./gradlew --version
+
+# 모든 서브모듈의 build.gradle 복사 (의존성 캐싱)
+COPY monew-api/build.gradle monew-api/
+COPY monew-batch/build.gradle monew-batch/
+COPY monew-monitor/build.gradle monew-monitor/
+
+# Gradle wrapper 실행 권한
+RUN chmod +x gradlew || true
+
+# 의존성 다운로드 (캐시 레이어)
+RUN ./gradlew dependencies --no-daemon || true
+
+# 전체 소스 코드 복사
+#COPY monew-batch .
+COPY monew-api/ monew-api/
+COPY monew-batch/ monew-batch/
+
+# Batch 모듈만 빌드 (테스트 제외)
+RUN ./gradlew :monew-batch:bootJar -x test --no-daemon
+
+# JAR 파일 위치 확인 및 복사
+RUN mkdir -p /app/build && \
+ find monew-batch/build/libs -name "*.jar" -not -name "*-plain.jar" -exec cp {} /app/build/app.jar \;
+
+# Stage 2: Runtime
+FROM eclipse-temurin:17-jre-alpine
+
+WORKDIR /app
+
+# 보안을 위한 non-root 사용자 생성
+RUN addgroup -S spring && adduser -S spring -G spring
+
+# 타임존 설정
+RUN apk add --no-cache tzdata && \
+ cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \
+ echo "Asia/Seoul" > /etc/timezone && \
+ apk del tzdata
+
+# 빌드 스테이지에서 JAR 파일 복사
+COPY --from=builder /app/build/app.jar app.jar
+
+# 로그 디렉토리 생성 및 권한 설정
+RUN mkdir -p /app/.logs && chown -R spring:spring /app/.logs
+
+# 소유권 변경
+RUN chown spring:spring app.jar
+
+# 사용자 변경
+USER spring:spring
+
+# JVM 옵션 설정 (배치 작업용 최적화)
+ENV JAVA_OPTS="-XX:+UseContainerSupport \
+ -XX:MaxRAMPercentage=75.0 \
+ -XX:InitialRAMPercentage=50.0 \
+ -XX:+UseG1GC \
+ -XX:MaxGCPauseMillis=200 \
+ -Djava.security.egd=file:/dev/./urandom"
+
+# 애플리케이션 포트
+EXPOSE 8081
+
+# 애플리케이션 실행
+ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
\ No newline at end of file
diff --git a/Dockerfile.monitor b/Dockerfile.monitor
new file mode 100644
index 0000000..34679de
--- /dev/null
+++ b/Dockerfile.monitor
@@ -0,0 +1,87 @@
+# ====================================
+# Dockerfile for Monitor Module
+# ====================================
+
+# Stage 1: Build
+FROM gradle:8.5-jdk17-alpine AS builder
+
+WORKDIR /app
+
+# Gradle wrapper 및 설정 파일 복사
+#COPY monew-monitor/build.gradle settings.gradle ./
+#COPY build.gradle ./
+#COPY gradlew* ./
+#COPY gradle gradle/
+
+COPY settings.gradle ./
+COPY build.gradle ./
+COPY gradlew ./
+COPY gradle gradle/
+COPY monew-monitor/build.gradle monew-monitor/
+
+# 2) CRLF 제거 + 실행권한 + 래퍼 존재/버전 확인
+RUN sed -i 's/\r$//' gradlew && chmod +x gradlew && \
+ ls -al && ls -al gradle/wrapper && head -n1 gradlew && \
+ ./gradlew --version
+
+# 모든 서브모듈의 build.gradle 복사 (의존성 캐싱)
+COPY monew-api/build.gradle monew-api/
+COPY monew-batch/build.gradle monew-batch/
+COPY monew-monitor/build.gradle monew-monitor/
+
+# Gradle wrapper 실행 권한
+RUN chmod +x gradlew || true
+
+# 의존성 다운로드 (캐시 레이어)
+RUN ./gradlew dependencies --no-daemon || true
+
+# 전체 소스 코드 복사
+#COPY monew-monitor .
+COPY monew-monitor/ monew-monitor/
+
+# Monitor 모듈만 빌드 (테스트 제외)
+RUN ./gradlew :monew-monitor:bootJar -x test --no-daemon
+
+# JAR 파일 위치 확인 및 복사
+RUN mkdir -p /app/build && \
+ find monew-monitor/build/libs -name "*.jar" -not -name "*-plain.jar" -exec cp {} /app/build/app.jar \;
+
+# Stage 2: Runtime
+FROM eclipse-temurin:17-jre-alpine
+
+WORKDIR /app
+
+# 보안을 위한 non-root 사용자 생성
+RUN addgroup -S spring && adduser -S spring -G spring
+
+# 타임존 설정
+RUN apk add --no-cache tzdata wget && \
+ cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \
+ echo "Asia/Seoul" > /etc/timezone && \
+ apk del tzdata
+
+# 빌드 스테이지에서 JAR 파일 복사
+COPY --from=builder /app/build/app.jar app.jar
+
+# 소유권 변경
+RUN chown spring:spring app.jar
+
+# 사용자 변경
+USER spring:spring
+
+# 헬스체크 설정
+HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider http://localhost:8082/actuator/health || exit 1
+
+# JVM 옵션 설정 (모니터링 경량화)
+ENV JAVA_OPTS="-XX:+UseContainerSupport \
+ -XX:MaxRAMPercentage=70.0 \
+ -XX:InitialRAMPercentage=40.0 \
+ -XX:+UseG1GC \
+ -Djava.security.egd=file:/dev/./urandom"
+
+# 애플리케이션 포트
+EXPOSE 8082
+
+# 애플리케이션 실행
+ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
\ No newline at end of file
diff --git a/Dockerfile.multi b/Dockerfile.multi
new file mode 100644
index 0000000..9aa5176
--- /dev/null
+++ b/Dockerfile.multi
@@ -0,0 +1,120 @@
+# ====================================
+# Multi-Module Multi-Stage Dockerfile
+# 한 번에 모든 모듈을 빌드하는 통합 Dockerfile
+# ====================================
+
+# Stage 1: Build All Modules
+FROM gradle:8.5-jdk17-alpine AS builder
+
+WORKDIR /app
+
+# Gradle wrapper 및 설정 파일 복사
+COPY build.gradle settings.gradle ./
+COPY gradlew* ./
+COPY gradle gradle/
+
+# CRLF 방지 + 실행권한
+RUN sed -i 's/\r$//' gradlew && chmod +x gradlew
+
+# 모든 서브모듈의 build.gradle 복사 (의존성 캐싱)
+COPY monew-api/build.gradle monew-api/
+COPY monew-batch/build.gradle monew-batch/
+COPY monew-monitor/build.gradle monew-monitor/
+
+# Gradle wrapper 실행 권한
+RUN chmod +x gradlew || true
+
+# 의존성 다운로드 (캐시 레이어)
+RUN gradle dependencies --no-daemon || true
+
+# 전체 소스 코드 복사
+COPY . .
+
+# 모든 모듈 빌드 (테스트 제외)
+RUN gradle build -x test --no-daemon
+
+# Stage 2: API Runtime
+FROM eclipse-temurin:17-jre-alpine AS api
+
+WORKDIR /app
+
+RUN addgroup -S spring && adduser -S spring -G spring
+
+RUN apk add --no-cache tzdata wget && \
+ cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \
+ echo "Asia/Seoul" > /etc/timezone && \
+ apk del tzdata
+
+COPY --from=builder /app/monew-api/build/libs/*[!plain].jar app.jar
+
+RUN chown spring:spring app.jar
+USER spring:spring
+
+HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1
+
+ENV JAVA_OPTS="-XX:+UseContainerSupport \
+ -XX:MaxRAMPercentage=75.0 \
+ -XX:InitialRAMPercentage=50.0 \
+ -XX:+UseG1GC \
+ -Djava.security.egd=file:/dev/./urandom"
+
+EXPOSE 8080
+
+ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
+
+# Stage 3: Batch Runtime
+FROM eclipse-temurin:17-jre-alpine AS batch
+
+WORKDIR /app
+
+RUN addgroup -S spring && adduser -S spring -G spring
+
+RUN apk add --no-cache tzdata && \
+ cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \
+ echo "Asia/Seoul" > /etc/timezone && \
+ apk del tzdata
+
+COPY --from=builder /app/monew-batch/build/libs/*[!plain].jar app.jar
+
+RUN chown spring:spring app.jar
+USER spring:spring
+
+ENV JAVA_OPTS="-XX:+UseContainerSupport \
+ -XX:MaxRAMPercentage=75.0 \
+ -XX:+UseG1GC \
+ -XX:MaxGCPauseMillis=200 \
+ -Djava.security.egd=file:/dev/./urandom"
+
+EXPOSE 8081
+
+ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
+
+# Stage 4: Monitor Runtime
+FROM eclipse-temurin:17-jre-alpine AS monitor
+
+WORKDIR /app
+
+RUN addgroup -S spring && adduser -S spring -G spring
+
+RUN apk add --no-cache tzdata wget && \
+ cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \
+ echo "Asia/Seoul" > /etc/timezone && \
+ apk del tzdata
+
+COPY --from=builder /app/monew-monitor/build/libs/*[!plain].jar app.jar
+
+RUN chown spring:spring app.jar
+USER spring:spring
+
+HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider http://localhost:8082/actuator/health || exit 1
+
+ENV JAVA_OPTS="-XX:+UseContainerSupport \
+ -XX:MaxRAMPercentage=70.0 \
+ -XX:+UseG1GC \
+ -Djava.security.egd=file:/dev/./urandom"
+
+EXPOSE 8082
+
+ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
\ No newline at end of file
diff --git a/README.md b/README.md
index ba4ab45..8ef75be 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,229 @@
-# monew-backend
-Spring Boot · JPA · Batch · PostgreSQL · MongoDB · AWS S3 기반 뉴스 통합 및 백업·복구 백엔드 시스템
+# 모뉴 (MONEW) - 마음대로 골라보는 모든 뉴스
+
+
+
+### 관련 자료
+
+> 협업 문서: [Notion 팀 문서 바로가기](https://www.notion.so/2-29228815f6f280ca8005d12bc9670225?pvs=21)
+
+
+
+## 📘 프로젝트 소개
+
+**MONEW**는 사용자의 관심사에 맞춰 뉴스를 자동으로 수집하고, 댓글·좋아요·알림 등 **소셜 기능을 통해 뉴스 경험을 확장하는 개인화 뉴스 플랫폼**입니다.
+
+* **프로젝트 기간:** 2025.10.17 ~ 2025.11.07
+* **기획 의도:** 넘쳐나는 뉴스 속에서 ‘나에게 맞는 뉴스’만 보고 싶은 사용자의 니즈 해결
+* **핵심 목표:**
+
+ * 관심사 기반 뉴스 큐레이션 및 자동 수집
+ * 사용자 활동(댓글, 좋아요, 알림) 통합 관리
+ * 스케줄러 기반 기사 동기화 및 S3 백업 복구 기능
+ * Prometheus + Grafana 기반 실시간 관찰성 확보
+
+---
+
+### 🎥 시연 영상
+https://github.com/user-attachments/assets/e1d18b5b-4470-41db-b318-1a91ae83fef4
+
+
+
+## 🛠 기술 스택
+
+| **구분** | **사용 기술** |
+| :-------------------------------- | :-------------------------------------------------------------------------------------------------- |
+| **Language & Core** | Java 17 |
+| **Framework / Runtime** | Spring Boot 3.x, Spring Batch |
+| **Database & ORM** | PostgreSQL, MongoDB, H2, Spring Data JPA, QueryDSL |
+| **Build & Dependency Management** | Gradle |
+| **Infra & DevOps** | AWS (ECS Fargate, ECR, RDS, S3), Docker, GitHub Actions |
+| **Monitoring & Metrics** | Spring Actuator, Prometheus, Grafana |
+| **API & Docs** | Spring REST Docs, Swagger (OpenAPI 3) |
+| **Utilities & Others** | Lombok, MapStruct, Jakarta Validation, Logback (MDC Logging), BCryptPasswordEncoder, JUnit, Postman |
+| **Collaboration Tools** | GitHub, Discord, Figma, Notion |
+
+
+
+## 💡 팀원 소개 및 주요 구현 기능
+
+### 🧾 임재혁
+
+* NAVER, 조선일보, 한경, 연합뉴스 API 기반 뉴스 자동 수집
+* Spring Batch 기반 수집 → 정제 → 저장 3단계 Job 구성
+* 기사 수집, 백업, 삭제 및 메트릭 수집 로직 구현
+* AWS S3 기반 JSON 백업/복구 기능 구축
+* Jacoco, Codecov 기반 CI 환경 구성 및 커버리지 측정 자동화
+
+> GitHub: [🔗 JaehyeokLim](https://github.com/JaehyeokLim)
+
+
+
+### 🔒 김찬혁
+
+* 댓글 CRUD 및 좋아요 기능 구현
+* Docker & AWS ECS 배포 환경 구성
+* GitHub Actions 통한 자동 배포(CI/CD) 파이프라인 구축
+* Prometheus + Grafana 기반 모니터링 환경 구성
+
+> GitHub: [🔗 chanhyeok0201](https://github.com/chanhyeok0201)
+
+
+
+### 🧩 강문영
+
+* 알림 CRUD 및 이벤트 기반 알림 시스템 구현
+* 스케줄링 기반 알림 삭제 로직 구현
+* JobExecutionListener를 사용하여 구독 뉴스 기사 수집 알림 기능 구현
+
+> GitHub: [🔗 truuuely](https://github.com/truuuely)
+
+
+
+### 🎓 이예림
+
+* 관심사 CRUD 및 구독 로직 구현
+* TDD 기반 테스트 코드 작성 및 검증 체계 구축
+
+> GitHub: [🔗 yeahlimm](https://github.com/yeahlimm)
+
+
+
+### ⏰ 정영진
+
+* 사용자 활동 내역(댓글, 좋아요, 뉴스 열람) 통합 조회 기능 구현
+* PostgreSQL → MongoDB 캐시 구조로 전환하여 조회 성능 개선
+* Docker & AWS ECS 배포 환경 구성
+* GitHub Actions 통한 자동 배포(CI/CD) 파이프라인 구축
+* Prometheus + Grafana 기반 모니터링 환경 구성
+
+> GitHub: [🔗 userjin2123](https://github.com/userjin2123)
+
+
+
+### 👤 최도한
+
+* 회원가입, 로그인, 탈퇴 기능 구현
+* BCrypt 기반 비밀번호 암호화 처리
+* 사용자 배치 및 메트릭 수집 로직 구성
+
+> GitHub: [🔗 DoHanChoi](https://github.com/DoHanChoi)
+
+
+
+### ERD
+
+
+
+
+
+## ⚙️ 시스템 아키텍처
+
+**데이터 성격에 따라 저장소를 분리하고, 클라우드 기반 자동 배포 및 모니터링 환경 구축**
+
+
+
+
+### 📁 파일 구조
+
+```plaintext
+monew-backend/
+├── .github/
+│ └── workflows/
+│ ├── deploy-api.yml
+│ ├── deploy-batch.yml
+│ └── deploy-monitor.yml
+│
+├── .gradle/
+├── .idea/
+├── build/
+├── gradle/
+├── logs/
+│
+├── monew-api/
+│ ├── src/
+│ │ ├── main/
+│ │ │ ├── java/com/monew/monew_api/
+│ │ │ │ ├── article/
+│ │ │ │ ├── comments/
+│ │ │ │ ├── common/
+│ │ │ │ ├── interest/
+│ │ │ │ ├── notification/
+│ │ │ │ ├── subscribe/
+│ │ │ │ ├── user/
+│ │ │ │ ├── useractivity/
+│ │ │ │ └── MonewApiApplication.java
+│ │ │ └── resources/
+│ │ │ ├── db/
+│ │ │ │ ├── data/
+│ │ │ │ └── schema.sql
+│ │ │ ├── static/
+│ │ │ │ ├── api/
+│ │ │ │ ├── assets/
+│ │ │ │ └── index.html
+│ │ │ ├── application.yml
+│ │ │ ├── application-dev.yml
+│ │ │ ├── application-prod.yml
+│ │ │ └── logback-spring.xml
+│ │ └── test/
+│ │ ├── java/com/monew/monew_api/
+│ │ │ ├── Comment/
+│ │ │ └── Notification/
+│ │ └── resources/application-test.yml
+│ └── build.gradle
+│
+├── monew-batch/
+│ ├── src/
+│ │ ├── main/
+│ │ │ ├── java/com/monew/monew_batch/
+│ │ │ │ ├── article/
+│ │ │ │ │ ├── config/
+│ │ │ │ │ ├── dto/
+│ │ │ │ │ ├── enums/
+│ │ │ │ │ ├── job/
+│ │ │ │ │ ├── matric/
+│ │ │ │ │ ├── properties/
+│ │ │ │ │ ├── repository/
+│ │ │ │ │ ├── scheduler/
+│ │ │ │ │ └── service/
+│ │ │ │ ├── common/
+│ │ │ │ ├── notification/
+│ │ │ │ ├── user/
+│ │ │ │ └── MonewBatchApplication.java
+│ │ │ └── resources/
+│ │ │ ├── application.yml
+│ │ │ ├── application-dev.yml
+│ │ │ ├── application-prod.yml
+│ │ │ └── schema-batch.sql
+│ │ └── test/
+│ │ ├── java/com/monew/monew_batch/s3/AWS3Test.java
+│ │ └── resources/application-test.yml
+│ └── build.gradle
+│
+├── monew-monitor/
+│ ├── src/
+│ │ ├── main/
+│ │ │ ├── java/com/monew/monew_monitor/
+│ │ │ │ └── MonewMonitorApplication.java
+│ │ │ └── resources/
+│ │ │ ├── application.yml
+│ │ │ └── prometheus.yml
+│ │ └── test/
+│ │ └── java/com/monew/monew_monitor/
+│ └── build.gradle
+│
+├── .dockerignore
+├── .env
+├── .env.example
+├── .gitignore
+├── build.gradle
+├── docker-compose.prod.yml
+├── Dockerfile.api
+├── Dockerfile.batch
+├── Dockerfile.monitor
+├── Dockerfile.multi
+├── gradlew
+├── gradlew.bat
+├── README.md
+└── settings.gradle
+```
+
diff --git a/build.gradle b/build.gradle
index 7d24310..576d6aa 100644
--- a/build.gradle
+++ b/build.gradle
@@ -15,12 +15,33 @@ java {
subprojects {
apply plugin: 'java'
+ apply plugin: 'jacoco'
apply plugin: 'io.spring.dependency-management'
repositories {
mavenCentral()
}
+ jacoco {
+ toolVersion = "0.8.10"
+ }
+
+ test {
+ useJUnitPlatform()
+ finalizedBy jacocoTestReport // 테스트 후 커버리지 자동 생성
+ systemProperty 'spring.profiles.active', 'test' // H2 프로필 강제 적용
+ }
+
+ jacocoTestReport {
+ dependsOn test
+ reports {
+ xml.required = true
+ csv.required = false
+ html.required = true
+ html.outputLocation = layout.buildDirectory.dir("jacocoHtml")
+ }
+ }
+
configurations {
compileOnly {
extendsFrom annotationProcessor
@@ -32,10 +53,8 @@ subprojects {
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+ implementation 'io.micrometer:micrometer-core'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
- }
-
- tasks.named('test') {
- useJUnitPlatform()
+ runtimeOnly "io.micrometer:micrometer-registry-prometheus"
}
}
\ No newline at end of file
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..bfb0964
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,166 @@
+version: '3.8'
+
+services:
+ # API Application (Production)
+ api:
+ build:
+ context: .
+ dockerfile: Dockerfile.api
+ args:
+ - BUILD_DATE=${BUILD_DATE}
+ - VCS_REF=${VCS_REF}
+ - VERSION=${VERSION:-latest}
+ image: monew-api:${VERSION:-latest}
+ container_name: monew-api-prod
+ environment:
+ SPRING_PROFILES_ACTIVE: prod
+ DB_URL: ${DB_URL}
+ DB_USERNAME: ${DB_USERNAME}
+ DB_PASSWORD: ${DB_PASSWORD}
+ MongoDB_URI: ${MongoDB_URI}
+ AWS_S3_ACCESS_KEY: ${AWS_S3_ACCESS_KEY}
+ AWS_S3_SECRET_KEY: ${AWS_S3_SECRET_KEY}
+ AWS_S3_REGION: ${AWS_S3_REGION}
+ AWS_S3_BUCKET: ${AWS_S3_BUCKET}
+ JAVA_OPTS: "-Xmx1g -Xms512m -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
+ ports:
+ - "${API_PORT:-8080}:8080"
+ networks:
+ - monew-network
+ restart: always
+ healthcheck:
+ test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/actuator/health" ]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 60s
+ deploy:
+ resources:
+ limits:
+ cpus: '2'
+ memory: 1536M
+ reservations:
+ cpus: '0.5'
+ memory: 512M
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # Batch Application (Production)
+ batch:
+ build:
+ context: .
+ dockerfile: Dockerfile.batch
+ args:
+ - BUILD_DATE=${BUILD_DATE}
+ - VCS_REF=${VCS_REF}
+ - VERSION=${VERSION:-latest}
+ image: monew-batch:${VERSION:-latest}
+ container_name: monew-batch-prod
+ environment:
+ SPRING_PROFILES_ACTIVE: prod
+ DB_URL: ${DB_URL}
+ DB_USERNAME: ${DB_USERNAME}
+ DB_PASSWORD: ${DB_PASSWORD}
+ AWS_S3_ACCESS_KEY: ${AWS_S3_ACCESS_KEY}
+ AWS_S3_SECRET_KEY: ${AWS_S3_SECRET_KEY}
+ AWS_S3_REGION: ${AWS_S3_REGION}
+ AWS_S3_BUCKET: ${AWS_S3_BUCKET}
+ NAVER_CLIENT_ID: ${NAVER_CLIENT_ID}
+ NAVER_CLIENT_SECRET: ${NAVER_CLIENT_SECRET}
+ MONEW_API_URL: ${MONEW_API_URL}
+ JAVA_OPTS: "-Xmx1g -Xms512m -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
+ ports:
+ - "${BATCH_PORT:-8081}:8081"
+ depends_on:
+ - api
+ networks:
+ - monew-network
+ restart: always
+ deploy:
+ resources:
+ limits:
+ cpus: '2'
+ memory: 1536M
+ reservations:
+ cpus: '0.5'
+ memory: 512M
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+ # Monitor Application (Production)
+ monitor:
+ build:
+ context: .
+ dockerfile: Dockerfile.monitor
+ args:
+ - BUILD_DATE=${BUILD_DATE}
+ - VCS_REF=${VCS_REF}
+ - VERSION=${VERSION:-latest}
+ image: monew-monitor:${VERSION:-latest}
+ container_name: monew-monitor-prod
+ environment:
+ SPRING_PROFILES_ACTIVE: prod
+ JAVA_OPTS: "-Xmx512m -Xms256m -XX:+UseContainerSupport -XX:MaxRAMPercentage=70.0"
+ ports:
+ - "${MONITOR_PORT:-8082}:8082"
+ depends_on:
+ - api
+ - batch
+ networks:
+ - monew-network
+ restart: always
+ healthcheck:
+ test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8082/actuator/health" ]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 40s
+ deploy:
+ resources:
+ limits:
+ cpus: '1'
+ memory: 768M
+ reservations:
+ cpus: '0.25'
+ memory: 256M
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # Prometheus (Metrics Collector)
+ prometheus:
+ image: prom/prometheus:latest
+ container_name: monew-prometheus
+ volumes:
+ - ./monew-monitor/src/main/resources/prometheus.yml:/etc/prometheus/prometheus.yml:ro
+ ports:
+ - "9090:9090"
+ networks:
+ - monew-network
+ restart: always
+
+ # Grafana (Visualization)
+ grafana:
+ image: grafana/grafana:latest
+ container_name: monew-grafana
+ environment:
+ - GF_SECURITY_ADMIN_USER=admin
+ - GF_SECURITY_ADMIN_PASSWORD=admin
+ ports:
+ - "3000:3000"
+ depends_on:
+ - prometheus
+ networks:
+ - monew-network
+ restart: always
+
+networks:
+ monew-network:
+ driver: bridge
\ No newline at end of file
diff --git a/monew-api/build.gradle b/monew-api/build.gradle
index 270ca6e..b84ba3a 100644
--- a/monew-api/build.gradle
+++ b/monew-api/build.gradle
@@ -6,13 +6,17 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
-// implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
+ implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
+ implementation 'software.amazon.awssdk:s3:2.31.7'
runtimeOnly 'org.postgresql:postgresql'
-// runtimeOnly 'com.h2database:h2'
+ runtimeOnly 'com.h2database:h2'
implementation 'org.mapstruct:mapstruct:1.6.3'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3'
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta'
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
+ 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' // 유사도 계산용
}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java b/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java
index 3e6016a..9c1f9f7 100644
--- a/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java
+++ b/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java
@@ -2,11 +2,18 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import org.springframework.scheduling.annotation.EnableAsync;
+
+import java.util.TimeZone;
@SpringBootApplication
+@EnableJpaAuditing
+@EnableAsync
public class MonewApiApplication {
public static void main(String[] args) {
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"));
SpringApplication.run(MonewApiApplication.class, args);
}
diff --git a/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java b/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java
new file mode 100644
index 0000000..2128c2a
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java
@@ -0,0 +1,118 @@
+package com.monew.monew_api.article.controller;
+
+import com.monew.monew_api.article.dto.ArticleDto;
+import com.monew.monew_api.article.dto.ArticleSearchRequest;
+import com.monew.monew_api.article.dto.ArticleViewDto;
+import com.monew.monew_api.article.dto.CursorPageResponseArticleDto;
+import com.monew.monew_api.article.service.ArticleService;
+import com.monew.monew_api.article.service.NewsRestoreService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/articles")
+public class ArticleController {
+
+ private final ArticleService articleService;
+ private final NewsRestoreService newsRestoreService;
+
+ /**
+ * 기사 조회 기록 등록
+ */
+ @PostMapping("/{articleId}/article-views")
+ public ResponseEntity viewArticle(
+ @PathVariable Long articleId,
+ @RequestHeader("Monew-Request-User-ID") Long userId
+ ) {
+ log.info("[API 요청] POST /api/articles/{}/article-views - 기사 조회 기록 요청, 사용자 ID: {}", articleId, userId);
+ ArticleViewDto dto = articleService.recordArticleView(articleId, userId);
+ log.info("[API 응답] POST /api/articles/{}/article-views - 조회 기록 성공, 조회 기록 ID: {}", articleId, dto.getId());
+ return ResponseEntity.status(HttpStatus.OK).body(dto);
+ }
+
+ /**
+ * 기사 목록 조회 (검색/필터/페이징 포함)
+ */
+ @GetMapping
+ public ResponseEntity> getArticles(
+ @Validated @ModelAttribute ArticleSearchRequest request,
+ @RequestHeader("Monew-Request-User-ID") Long userId
+ ) {
+ log.info("[API 요청] GET /api/articles - 기사 목록 조회 요청, 사용자 ID: {}, 키워드: {}, 관심사 ID: {}, 커서: {}, After: {}",
+ userId, request.getKeyword(), request.getInterestId(), request.getCursor(), request.getAfter());
+ CursorPageResponseArticleDto dto = articleService.getArticles(request, userId);
+ log.info("[API 응답] GET /api/articles - 조회 성공, 반환된 기사 수: {}", dto.getContent().size());
+ return ResponseEntity.ok(dto);
+ }
+
+ /**
+ * 단일 기사 상세 조회
+ */
+ @GetMapping("/{articleId}")
+ public ResponseEntity getArticleById(
+ @PathVariable Long articleId,
+ @RequestHeader("Monew-Request-User-ID") Long userId
+ ) {
+ log.info("[API 요청] GET /api/articles/{} - 기사 상세 조회 요청, 사용자 ID: {}", articleId, userId);
+ ArticleDto dto = articleService.findArticle(articleId, userId);
+
+ if (!dto.isViewedByMe()) {
+ articleService.recordArticleView(articleId, userId);
+ }
+
+ log.info("[API 응답] GET /api/articles/{} - 기사 상세 조회 성공", articleId);
+ return ResponseEntity.status(HttpStatus.OK).body(dto);
+ }
+
+ /**
+ * 기사 출처 목록 조회
+ */
+ @GetMapping("/sources")
+ public ResponseEntity> getSources() {
+ log.info("[API 요청] GET /api/articles/sources - 뉴스 출처 목록 조회 요청");
+ List sources = articleService.getAllSources();
+ log.info("[API 응답] GET /api/articles/sources - 뉴스 출처 목록 조회 성공, 개수: {}", sources.size());
+ return ResponseEntity.status(HttpStatus.OK).body(sources);
+ }
+
+ /**
+ * 기사 논리 삭제
+ */
+ @DeleteMapping("/{articleId}")
+ public ResponseEntity deleteArticle(@PathVariable Long articleId) {
+ log.info("[API 요청] DELETE /api/articles/{} - 기사 논리 삭제 요청", articleId);
+ articleService.softDeleteArticle(articleId);
+ log.info("[API 응답] DELETE /api/articles/{} - 기사 논리 삭제 성공", articleId);
+ return ResponseEntity.noContent().build();
+ }
+
+ /**
+ * 기사 영구 삭제
+ */
+ @DeleteMapping("/{articleId}/hard")
+ public ResponseEntity hardDeleteArticle(@PathVariable Long articleId) {
+ log.info("[API 요청] DELETE /api/articles/{}/hard - 기사 영구 삭제 요청", articleId);
+ articleService.hardDeleteArticle(articleId);
+ log.info("[API 응답] DELETE /api/articles/{}/hard - 기사 영구 삭제 성공", articleId);
+ return ResponseEntity.noContent().build();
+ }
+
+ @GetMapping("/restore")
+ public ResponseEntity restoreBackup(
+ @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
+ @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to
+ ) {
+ newsRestoreService.restoreArticles(from, to);
+ return ResponseEntity.ok("복원 완료");
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleBackupData.java b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleBackupData.java
new file mode 100644
index 0000000..85ce1ba
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleBackupData.java
@@ -0,0 +1,67 @@
+package com.monew.monew_api.article.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.monew.monew_api.article.entity.Article;
+import com.querydsl.core.annotations.QueryProjection;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+/**
+ * 뉴스 백업 데이터 구조
+ * - S3 저장용 DTO
+ * - 기사 정보 + 연결된 키워드 목록 포함
+ */
+@Getter
+@Setter
+@NoArgsConstructor
+public class ArticleBackupData {
+
+ private LocalDateTime backupDate;
+ private List articles;
+
+ @Getter
+ @Setter
+ @NoArgsConstructor
+ public static class ArticleData {
+
+ private String source;
+ private String sourceUrl;
+ private String title;
+ private LocalDateTime publishDate;
+ private String summary;
+
+ @JsonProperty("keywords")
+ private List keywords;
+
+ /**
+ * QueryProjection 기반 생성자
+ * - string_agg 결과 문자열을 List으로 변환
+ */
+ @QueryProjection
+ public ArticleData(String source, String sourceUrl, String title,
+ LocalDateTime publishDate, String summary, String keywordsRaw) {
+ this.source = source;
+ this.sourceUrl = sourceUrl;
+ this.title = title;
+ this.publishDate = publishDate;
+ this.summary = summary;
+ this.keywords = Arrays.stream(Optional.ofNullable(keywordsRaw).orElse("").split(","))
+ .map(String::trim)
+ .filter(s -> !s.isBlank())
+ .distinct()
+ .collect(Collectors.toList());
+ }
+
+ /** Entity 변환용 헬퍼 */
+ public Article toEntity() {
+ return new Article(source, sourceUrl, title, publishDate, summary);
+ }
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleDto.java b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleDto.java
new file mode 100644
index 0000000..284a31c
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleDto.java
@@ -0,0 +1,44 @@
+package com.monew.monew_api.article.dto;
+
+import com.querydsl.core.annotations.QueryProjection;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 단일 기사 응답 DTO
+ */
+@Getter
+@Builder
+@NoArgsConstructor
+public class ArticleDto {
+
+ private Long id; // 기사 ID
+ private String source; // 출처
+ private String sourceUrl; // 원본 URL
+ private String title; // 제목
+ private LocalDateTime publishDate; // 발행일
+ private String summary; // 요약
+ private int commentCount; // 댓글 수
+ private int viewCount; // 조회 수
+ private boolean viewedByMe; // 내가 조회했는지 여부
+
+ @QueryProjection
+ public ArticleDto(
+ Long id, String source, String sourceUrl,
+ String title, LocalDateTime publishDate, String summary,
+ int commentCount, int viewCount, boolean viewedByMe
+ ) {
+ this.id = id;
+ this.source = source;
+ this.sourceUrl = sourceUrl;
+ this.title = title;
+ this.publishDate = publishDate;
+ this.summary = summary;
+ this.commentCount = commentCount;
+ this.viewCount = viewCount;
+ this.viewedByMe = viewedByMe;
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleSearchRequest.java b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleSearchRequest.java
new file mode 100644
index 0000000..525c1c1
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleSearchRequest.java
@@ -0,0 +1,51 @@
+package com.monew.monew_api.article.dto;
+
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+public class ArticleSearchRequest {
+
+ @Size(max = 50, message = "검색어(keyword)는 최대 50자까지 입력할 수 있습니다.")
+ private String keyword;
+
+ private Long interestId;
+
+ private List sourceIn = List.of("Naver");
+
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
+ private LocalDateTime publishDateFrom = LocalDateTime.now().minusDays(7);
+
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
+ private LocalDateTime publishDateTo = LocalDateTime.now();
+
+ @Pattern(regexp = "^(publishDate|viewCount|commentCount)$",
+ message = "정렬 기준(orderBy)은 publishDate, viewCount, commentCount 중 하나여야 합니다.")
+ private String orderBy = "publishDate";
+
+ @Pattern(regexp = "^(ASC|DESC)$",
+ message = "정렬 방향(direction)은 ASC 또는 DESC만 가능합니다.")
+ private String direction = "DESC";
+
+ private String cursor;
+
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
+ private LocalDateTime after;
+
+ @Min(value = 1, message = "limit은 1 이상이어야 합니다.")
+ @Max(value = 50, message = "limit은 최대 50까지만 가능합니다.")
+ private int limit = 10;
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleViewDto.java b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleViewDto.java
new file mode 100644
index 0000000..8f7069c
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleViewDto.java
@@ -0,0 +1,31 @@
+package com.monew.monew_api.article.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+/**
+ * 기사 조회 기록 응답 DTO
+ */
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ArticleViewDto {
+
+ private Long id; // 조회 기록 ID
+ private Long viewedBy; // 조회한 사용자 ID
+ private LocalDateTime createdAt; // 조회 시각
+ private Long articleId; // 기사 ID
+ private String source; // 기사 출처
+ private String sourceUrl; // 기사 원본 URL
+ private String articleTitle; // 기사 제목
+ private LocalDateTime articlePublishedDate; // 기사 발행일
+ private String articleSummary; // 기사 요약
+ private int articleCommentCount; // 댓글 수
+ private int articleViewCount; // 조회 수
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/article/dto/CursorPageResponseArticleDto.java b/monew-api/src/main/java/com/monew/monew_api/article/dto/CursorPageResponseArticleDto.java
new file mode 100644
index 0000000..1471503
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/article/dto/CursorPageResponseArticleDto.java
@@ -0,0 +1,26 @@
+package com.monew.monew_api.article.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 커서 기반 페이지 응답 DTO
+ */
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CursorPageResponseArticleDto {
+
+ private List content; // 페이지 데이터
+ private String nextCursor; // 다음 커서 값
+ private LocalDateTime nextAfter; // 커서 기준 다음 시각
+ private int size; // 요청한 페이지 크기
+ private long totalElements; // 전체 데이터 수
+ private boolean hasNext; // 다음 페이지 여부
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java
new file mode 100644
index 0000000..9d90d36
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java
@@ -0,0 +1,78 @@
+package com.monew.monew_api.article.entity;
+
+import com.monew.monew_api.common.entity.BaseIdEntity;
+import com.monew.monew_api.common.exception.article.ArticleNotFoundException;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 뉴스 기사 테이블
+ */
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+@Table(name = "articles")
+public class Article extends BaseIdEntity {
+
+ @Column(nullable = false, length = 20)
+ private String source;
+
+ @Column(name = "source_url", nullable = false, length = 500, unique = true)
+ private String sourceUrl;
+
+ @Column(nullable = false, length = 200)
+ private String title;
+
+ @Column(name = "publish_date", nullable = false)
+ private LocalDateTime publishDate;
+
+ @Column(nullable = false, length = 200)
+ private String summary;
+
+ @Column(name = "comment_count", nullable = false)
+ private int commentCount = 0;
+
+ @Column(name = "view_count", nullable = false)
+ private int viewCount = 0;
+
+ @Column(name = "is_deleted", nullable = false)
+ private boolean isDeleted = false;
+
+ @OneToMany(mappedBy = "article", cascade = CascadeType.ALL, orphanRemoval = true)
+ private List interestArticles = new ArrayList<>();
+
+ public Article(String source, String sourceUrl, String title, LocalDateTime publishDate, String summary) {
+ this.source = source;
+ this.sourceUrl = sourceUrl;
+ this.title = title;
+ this.publishDate = publishDate;
+ this.summary = summary;
+ }
+
+ public void softDelete() {
+ if (this.isDeleted) {
+ throw new ArticleNotFoundException();
+ }
+ this.isDeleted = true;
+ }
+
+ public void increaseCommentCount() {
+ this.commentCount++;
+ }
+
+ public void decreaseCommentCount() {
+ if (this.commentCount > 0) {
+ this.commentCount--;
+ }
+ }
+
+ public void increaseViewCount() {
+ this.viewCount++;
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleView.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleView.java
new file mode 100644
index 0000000..c17d2e2
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleView.java
@@ -0,0 +1,32 @@
+package com.monew.monew_api.article.entity;
+
+import com.monew.monew_api.common.entity.BaseCreatedEntity;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 뉴스 기사 조회 테이블
+ */
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@Entity
+@Table(
+ name = "article_views",
+ uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "article_id"}),
+ indexes = {
+ @Index(name = "ix_article_views_user", columnList = "user_id"),
+ @Index(name = "ix_article_views_article", columnList = "article_id")
+ }
+)
+public class ArticleView extends BaseCreatedEntity {
+
+ @Column(name = "user_id", nullable = false)
+ private Long userId;
+
+ @Column(name = "article_id", nullable = false)
+ private Long articleId;
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticleKeyword.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticleKeyword.java
new file mode 100644
index 0000000..73d4b60
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticleKeyword.java
@@ -0,0 +1,29 @@
+package com.monew.monew_api.article.entity;
+
+import com.monew.monew_api.common.entity.BaseIdEntity;
+import com.monew.monew_api.interest.entity.Keyword;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+@Table(
+ name = "interest_articles_keywords",
+ uniqueConstraints = @UniqueConstraint(
+ name = "uq_interest_articles_keywords",
+ columnNames = {"interest_article_id", "keyword_id"}
+ )
+)
+public class InterestArticleKeyword extends BaseIdEntity {
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "interest_article_id", nullable = false)
+ private InterestArticles interestArticle;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "keyword_id", nullable = false)
+ private Keyword keyword;
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java
new file mode 100644
index 0000000..f13db04
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java
@@ -0,0 +1,35 @@
+package com.monew.monew_api.article.entity;
+
+import com.monew.monew_api.common.entity.BaseIdEntity;
+import com.monew.monew_api.interest.entity.Interest;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 기사 - 관심사 연결 테이블
+ */
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+@Table(
+ name = "interest_articles",
+ uniqueConstraints = @UniqueConstraint(columnNames = {"article_id", "interest_id"})
+)
+public class InterestArticles extends BaseIdEntity {
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "article_id", nullable = false)
+ private Article article;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "interest_id", nullable = false)
+ private Interest interest;
+
+ @OneToMany(mappedBy = "interestArticle", cascade = CascadeType.ALL, orphanRemoval = true)
+ private List interestArticleKeywords = new ArrayList<>();
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/article/event/ArticleViewedEvent.java b/monew-api/src/main/java/com/monew/monew_api/article/event/ArticleViewedEvent.java
new file mode 100644
index 0000000..f18bcdc
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/article/event/ArticleViewedEvent.java
@@ -0,0 +1,61 @@
+package com.monew.monew_api.article.event;
+
+import java.time.LocalDateTime;
+
+/**
+ * 기사 조회 이벤트
+ * 사용자가 기사를 조회했을 때 발행
+ * Update 전략 사용
+ * @param viewId
+ * @param userId
+ * @param createdAt
+ * @param articleId
+ * @param source
+ * @param sourceUrl
+ * @param articleTitle
+ * @param articlePublishedDate
+ * @param articleSummary
+ * @param articleCommentCount
+ * @param articleViewCount
+ * @param occurredAt
+ */
+public record ArticleViewedEvent(
+ Long viewId,
+ Long userId,
+ LocalDateTime createdAt,
+ Long articleId,
+ String source,
+ String sourceUrl,
+ String articleTitle,
+ LocalDateTime articlePublishedDate,
+ String articleSummary,
+ Integer articleCommentCount,
+ Integer articleViewCount,
+ LocalDateTime occurredAt
+) {
+ public static ArticleViewedEvent of(
+ Long id,
+ Long userId,
+ LocalDateTime createdAt,
+ Long articleId,
+ String source,
+ String sourceUrl,
+ String articleTitle,
+ LocalDateTime articlePublishedDate,
+ String articleSummary,
+ Integer articleCommentCount,
+ Integer articleViewCount
+ ) {
+ return new ArticleViewedEvent(
+ id, userId, createdAt, articleId,
+ source, sourceUrl, articleTitle,
+ articlePublishedDate, articleSummary,
+ articleCommentCount, articleViewCount,
+ LocalDateTime.now()
+ );
+ }
+
+ public Integer getDelta() {
+ return +1;
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleJdbcRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleJdbcRepository.java
new file mode 100644
index 0000000..3b0bda2
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleJdbcRepository.java
@@ -0,0 +1,33 @@
+package com.monew.monew_api.article.repository;
+
+import com.monew.monew_api.article.entity.Article;
+import lombok.RequiredArgsConstructor;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+@Repository
+@RequiredArgsConstructor
+public class ArticleJdbcRepository {
+
+ private final JdbcTemplate jdbcTemplate;
+
+ public boolean insertIgnore(Article article) {
+ String sql = """
+ INSERT INTO articles (source, source_url, title, summary, publish_date, comment_count, view_count, is_deleted)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (source_url) DO NOTHING
+ """;
+
+ int rows = jdbcTemplate.update(sql,
+ article.getSource(),
+ article.getSourceUrl(),
+ article.getTitle(),
+ article.getSummary(),
+ article.getPublishDate(),
+ article.getCommentCount(),
+ article.getViewCount(),
+ article.isDeleted());
+
+ return rows > 0;
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepository.java
new file mode 100644
index 0000000..5a296b0
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepository.java
@@ -0,0 +1,17 @@
+package com.monew.monew_api.article.repository;
+
+import com.monew.monew_api.article.dto.ArticleDto;
+import com.monew.monew_api.article.dto.CursorPageResponseArticleDto;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+public interface ArticleQueryRepository {
+
+ CursorPageResponseArticleDto searchArticles(
+ String keyword, Long interestId, List sourceIn,
+ LocalDateTime publishDateFrom, LocalDateTime publishDateTo,
+ String orderBy, String direction,
+ String cursor, LocalDateTime after, int limit, Long userId
+ );
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepositoryImpl.java
new file mode 100644
index 0000000..3602c2d
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepositoryImpl.java
@@ -0,0 +1,174 @@
+package com.monew.monew_api.article.repository;
+
+import com.monew.monew_api.article.dto.ArticleDto;
+import com.monew.monew_api.article.dto.CursorPageResponseArticleDto;
+import com.monew.monew_api.article.dto.QArticleDto;
+import com.monew.monew_api.article.entity.Article;
+import com.querydsl.core.types.OrderSpecifier;
+import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.jpa.JPAExpressions;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static com.monew.monew_api.article.entity.QArticle.article;
+import static com.monew.monew_api.article.entity.QArticleView.articleView;
+import static com.monew.monew_api.article.entity.QInterestArticles.interestArticles;
+
+@Slf4j
+@RequiredArgsConstructor
+public class ArticleQueryRepositoryImpl implements ArticleQueryRepository {
+
+ private final JPAQueryFactory queryFactory;
+
+ @Override
+ public CursorPageResponseArticleDto searchArticles(
+ String keyword, Long interestId, List sourceIn,
+ LocalDateTime publishDateFrom, LocalDateTime publishDateTo,
+ String orderBy, String direction,
+ String cursor, LocalDateTime after, int limit, Long userId
+ ) {
+ List articles = queryFactory
+ .select(new QArticleDto(
+ article.id,
+ article.source,
+ article.sourceUrl,
+ article.title,
+ article.publishDate,
+ article.summary,
+ article.commentCount,
+ article.viewCount,
+ JPAExpressions
+ .selectOne()
+ .from(articleView)
+ .where(
+ articleView.articleId.eq(article.id)
+ .and(articleView.userId.eq(userId))
+ )
+ .exists()
+ ))
+ .from(article)
+ .where(
+ article.isDeleted.isFalse(),
+ keywordContains(keyword),
+ interestEq(interestId),
+ sourceIn(sourceIn),
+ publishDateBetween(publishDateFrom, publishDateTo),
+ cursorCondition(cursor, orderBy, direction)
+ )
+ .orderBy(order(orderBy, direction))
+ .limit(limit + 1)
+ .fetch();
+
+ boolean hasNext = articles.size() > limit;
+ if (hasNext) articles.remove(limit);
+
+ ArticleDto last = hasNext ? articles.get(articles.size() - 1) : null;
+
+ return CursorPageResponseArticleDto.builder()
+ .content(articles)
+ .nextCursor(last != null ? String.valueOf(last.getId()) : null)
+ .nextAfter(null)
+ .size(limit)
+ .hasNext(hasNext)
+ .build();
+ }
+
+ private BooleanExpression keywordContains(String keyword) {
+ if (keyword == null || keyword.isBlank()) return null;
+ return article.title.containsIgnoreCase(keyword)
+ .or(article.summary.containsIgnoreCase(keyword));
+ }
+
+ // 추후 읽기 성능이 중요해진다 생각되면 join으로 변경 가능
+ private BooleanExpression interestEq(Long interestId) {
+ if (interestId == null) return null;
+ return article.id.in(
+ JPAExpressions.select(interestArticles.article.id)
+ .from(interestArticles)
+ .where(interestArticles.interest.id.eq(interestId))
+ );
+ }
+
+ private BooleanExpression sourceIn(List sourceIn) {
+ if (sourceIn == null || sourceIn.isEmpty()) return null;
+ return article.source.in(sourceIn);
+ }
+
+ private BooleanExpression publishDateBetween(LocalDateTime from, LocalDateTime to) {
+ if (from == null || to == null) return null;
+ return article.publishDate.between(from, to);
+ }
+
+ // Java 14 이상에서 도입된 Switch Expression 문법 도입
+ private OrderSpecifier>[] order(String orderBy, String direction) {
+ boolean asc = "ASC".equalsIgnoreCase(direction);
+
+ return switch (orderBy) {
+ case "commentCount" -> asc
+ ? new OrderSpecifier[]{article.commentCount.asc(), article.id.asc()}
+ : new OrderSpecifier[]{article.commentCount.desc(), article.id.desc()};
+ case "viewCount" -> asc
+ ? new OrderSpecifier[]{article.viewCount.asc(), article.id.asc()}
+ : new OrderSpecifier[]{article.viewCount.desc(), article.id.desc()};
+ default -> asc
+ ? new OrderSpecifier[]{article.publishDate.asc(), article.id.asc()}
+ : new OrderSpecifier[]{article.publishDate.desc(), article.id.desc()};
+ };
+ }
+
+ // after는 사실상 무쓸모, 즉 정렬 기준을 사용해야함.
+ private BooleanExpression cursorCondition(
+ String cursor, String orderBy, String direction) {
+
+ if (cursor == null) return null;
+
+ boolean desc = "DESC".equalsIgnoreCase(direction);
+ Long cursorId = Long.valueOf(cursor);
+
+ Article cursorArticle = queryFactory
+ .selectFrom(article)
+ .where(article.id.eq(cursorId))
+ .fetchOne();
+
+ if (cursorArticle == null) return null;
+
+ return switch (orderBy) {
+ case "commentCount" -> {
+ int afterComment = cursorArticle.getCommentCount();
+ yield desc
+ ? article.commentCount.lt(afterComment)
+ .or(article.commentCount.eq(afterComment)
+ .and(article.id.lt(cursorId)))
+ : article.commentCount.gt(afterComment)
+ .or(article.commentCount.eq(afterComment)
+ .and(article.id.gt(cursorId)));
+ }
+
+ case "viewCount" -> {
+ int afterView = cursorArticle.getViewCount();
+ yield desc
+ ? article.viewCount.lt(afterView)
+ .or(article.viewCount.eq(afterView)
+ .and(article.id.lt(cursorId)))
+ : article.viewCount.gt(afterView)
+ .or(article.viewCount.eq(afterView)
+ .and(article.id.gt(cursorId)));
+ }
+
+ default -> {
+ LocalDateTime afterDate = cursorArticle.getPublishDate();
+ yield desc
+ ? article.publishDate.lt(afterDate)
+ .or(article.publishDate.eq(afterDate)
+ .and(article.id.lt(cursorId)))
+ : article.publishDate.gt(afterDate)
+ .or(article.publishDate.eq(afterDate)
+ .and(article.id.gt(cursorId)));
+ }
+ };
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java
new file mode 100644
index 0000000..0281d99
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java
@@ -0,0 +1,39 @@
+package com.monew.monew_api.article.repository;
+
+import com.monew.monew_api.article.entity.Article;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+public interface ArticleRepository extends JpaRepository, ArticleQueryRepository {
+
+ /** 논리 삭제되지 않은 기사 단건 조회 */
+ Optional findByIdAndIsDeletedFalse(Long id);
+
+ /** 기사 출처(source) 중복 없이 조회 */
+ @Query("SELECT DISTINCT a.source FROM Article a WHERE a.isDeleted = false")
+ List findDistinctSources();
+
+ /** 기사 URL로 중복 여부 확인 (뉴스 중복 방지용) */
+ Optional findBySourceUrl(String sourceUrl);
+
+ /** 여러 기사 논리 삭제 (isDeleted = true) */
+ @Modifying(clearAutomatically = true)
+ @Query("""
+ UPDATE Article a
+ SET a.isDeleted = true
+ WHERE a.id IN :articleIds
+ """)
+ void markAsDeleted(@Param("articleIds") List articleIds);
+
+ /** 논리 삭제된 기사 전체 조회 (스케줄러 등에서 사용) */
+ List findAllByIsDeletedTrue();
+
+ @Query("SELECT a.sourceUrl FROM Article a")
+ Set findAllSourceUrls();
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleViewRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleViewRepository.java
new file mode 100644
index 0000000..509a766
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleViewRepository.java
@@ -0,0 +1,9 @@
+package com.monew.monew_api.article.repository;
+
+import com.monew.monew_api.article.entity.ArticleView;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface ArticleViewRepository extends JpaRepository {
+
+ boolean existsByUserIdAndArticleId(Long userId, Long articleId);
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticleKeywordRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticleKeywordRepository.java
new file mode 100644
index 0000000..d32ef07
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticleKeywordRepository.java
@@ -0,0 +1,50 @@
+package com.monew.monew_api.article.repository;
+
+import com.monew.monew_api.article.entity.InterestArticleKeyword;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+
+public interface InterestArticleKeywordRepository extends JpaRepository {
+
+ /**
+ * 특정 키워드들이 연결된 기사 ID 목록 조회
+ */
+ @Query("""
+ SELECT DISTINCT iak.interestArticle.article.id
+ FROM InterestArticleKeyword iak
+ WHERE iak.keyword.id IN :keywordIds
+ """)
+ List findArticleIdsByKeywordIds(@Param("keywordIds") List keywordIds);
+
+ /**
+ * 주어진 키워드가 아닌 다른 키워드나 관심사에서도
+ * 동일한 기사가 사용 중인지 확인
+ */
+ @Query("""
+ SELECT DISTINCT iak.interestArticle.article.id
+ FROM InterestArticleKeyword iak
+ WHERE iak.interestArticle.article.id IN :articleIds
+ AND (iak.interestArticle.interest.id <> :interestId
+ OR iak.keyword.id NOT IN :keywordIds)
+ """)
+ List findArticlesUsedElsewhere(
+ @Param("articleIds") List articleIds,
+ @Param("keywordIds") List keywordIds,
+ @Param("interestId") Long interestId
+ );
+
+ @Modifying
+ @Query(value = """
+ INSERT INTO interest_articles_keywords (interest_article_id, keyword_id)
+ VALUES (:interestArticleId, :keywordId)
+ ON CONFLICT (interest_article_id, keyword_id) DO NOTHING
+ """, nativeQuery = true)
+ int insertIgnore(
+ @Param("interestArticleId") Long interestArticleId,
+ @Param("keywordId") Long keywordId
+ );
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticlesRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticlesRepository.java
new file mode 100644
index 0000000..15811d5
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticlesRepository.java
@@ -0,0 +1,55 @@
+package com.monew.monew_api.article.repository;
+
+import com.monew.monew_api.article.entity.Article;
+import com.monew.monew_api.article.entity.InterestArticles;
+import com.monew.monew_api.interest.entity.Interest;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface InterestArticlesRepository extends JpaRepository {
+
+ /** 특정 관심사(interestId)에 연결된 모든 기사 ID 조회 */
+ @Query("""
+ SELECT ia.article.id
+ FROM InterestArticles ia
+ WHERE ia.interest.id = :interestId
+ """)
+ List findArticleIdsByInterestId(@Param("interestId") Long interestId);
+
+ /**
+ * 주어진 기사들(articleIds)이
+ * 현재 관심사(interestId)를 제외한 다른 관심사에서도 사용 중인지 확인.
+ * (즉, “공유된 기사”를 식별하기 위한 쿼리)
+ */
+ @Query("""
+ SELECT DISTINCT ia.article.id
+ FROM InterestArticles ia
+ WHERE ia.article.id IN :articleIds
+ AND ia.interest.id <> :interestId
+ """)
+ List findArticleIdsUsedByOtherInterests(
+ @Param("articleIds") List articleIds,
+ @Param("interestId") Long interestId
+ );
+
+ /** 특정 기사와 관심사 간의 연결이 이미 존재하는지 확인 */
+ Optional findByArticleAndInterest(Article article, Interest interest);
+
+ @Modifying
+ @Query(
+ value = """
+ INSERT INTO interest_articles (interest_id, article_id, created_at, updated_at)
+ VALUES (:interestId, :articleId, NOW(), NOW())
+ ON CONFLICT (interest_id, article_id) DO NOTHING
+ """,
+ nativeQuery = true
+ )
+ int insertIgnore(
+ @Param("interestId") Long interestId,
+ @Param("articleId") Long articleId);
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java b/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java
new file mode 100644
index 0000000..61223c6
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java
@@ -0,0 +1,197 @@
+package com.monew.monew_api.article.service;
+
+import com.monew.monew_api.article.dto.ArticleDto;
+import com.monew.monew_api.article.dto.ArticleSearchRequest;
+import com.monew.monew_api.article.dto.ArticleViewDto;
+import com.monew.monew_api.article.dto.CursorPageResponseArticleDto;
+import com.monew.monew_api.article.entity.Article;
+import com.monew.monew_api.article.entity.ArticleView;
+import com.monew.monew_api.article.repository.ArticleRepository;
+import com.monew.monew_api.article.repository.ArticleViewRepository;
+import com.monew.monew_api.common.exception.article.ArticleNotFoundException;
+import com.monew.monew_api.article.event.ArticleViewedEvent;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class ArticleService {
+
+ private final ArticleRepository articleRepository;
+ private final ArticleViewRepository articleViewRepository;
+ private final ApplicationEventPublisher eventPublisher;
+
+ /**
+ * 기사 조회 기록 등록
+ */
+ @Transactional
+ public ArticleViewDto recordArticleView(Long articleId, Long userId) {
+ log.info("[기사 조회 기록 시도] 기사 ID: {}, 사용자 ID: {}", articleId, userId);
+
+ if (articleViewRepository.existsByUserIdAndArticleId(userId, articleId)) {
+ log.warn("[조회 기록 실패] 이미 조회한 기사입니다. 사용자 ID: {}, 기사 ID: {}", userId, articleId);
+
+ Article article = articleRepository.findByIdAndIsDeletedFalse(articleId)
+ .orElseThrow(ArticleNotFoundException::new);
+
+ return ArticleViewDto.builder()
+ .id(null)
+ .viewedBy(userId)
+ .createdAt(LocalDateTime.now())
+ .articleId(articleId)
+ .source(article.getSource())
+ .sourceUrl(article.getSourceUrl())
+ .articleTitle(article.getTitle())
+ .articlePublishedDate(article.getPublishDate())
+ .articleSummary(article.getSummary())
+ .articleCommentCount(article.getCommentCount())
+ .articleViewCount(article.getViewCount())
+ .build();
+ }
+
+ Article article = articleRepository.findByIdAndIsDeletedFalse(articleId)
+ .orElseThrow(() -> {
+ log.warn("[조회 기록 실패] 존재하지 않는 기사: {}", articleId);
+ return new ArticleNotFoundException();
+ });
+
+ ArticleView articleView = new ArticleView(userId, articleId);
+ ArticleView saved = articleViewRepository.save(articleView);
+ article.increaseViewCount();
+ eventPublisher.publishEvent(
+ ArticleViewedEvent.of(
+ saved.getId(),
+ saved.getUserId(),
+ saved.getCreatedAt(),
+ saved.getArticleId(),
+ article.getSource(),
+ article.getSourceUrl(),
+ article.getTitle(),
+ article.getPublishDate(),
+ article.getSummary(),
+ article.getCommentCount(),
+ article.getViewCount()));
+ log.info("[조회 기록 성공] 기사 ID: {}, 사용자 ID: {}", articleId, userId);
+
+ return ArticleViewDto.builder()
+ .id(saved.getId())
+ .viewedBy(userId)
+ .createdAt(saved.getCreatedAt())
+ .articleId(articleId)
+ .source(article.getSource())
+ .sourceUrl(article.getSourceUrl())
+ .articleTitle(article.getTitle())
+ .articlePublishedDate(article.getPublishDate())
+ .articleSummary(article.getSummary())
+ .articleCommentCount(article.getCommentCount())
+ .articleViewCount(article.getViewCount() + 1)
+ .build();
+ }
+
+ /**
+ * 기사 목록 조회 (검색/필터/페이징 포함)
+ */
+ public CursorPageResponseArticleDto getArticles(ArticleSearchRequest request, Long userId) {
+ String keyword = request.getKeyword();
+ Long interestId = request.getInterestId();
+
+ if ((keyword == null || keyword.isBlank()) && interestId == null) {
+ interestId = 1L;
+ } else if (keyword != null && !keyword.isBlank() && interestId != null) {
+ keyword = null;
+ }
+
+ log.info("[기사 목록 조회] 사용자 ID: {}, 키워드: {}, 관심사 ID: {}", userId, keyword, interestId);
+
+ CursorPageResponseArticleDto result = articleRepository.searchArticles(
+ keyword, interestId, request.getSourceIn(),
+ request.getPublishDateFrom(), request.getPublishDateTo(),
+ request.getOrderBy(), request.getDirection(),
+ request.getCursor(), request.getAfter(), request.getLimit(), userId
+ );
+
+ log.info("[기사 목록 조회 완료] 조회된 기사 수: {}, 커서: {}, After: {}",
+ result.getContent().size(), result.getNextCursor(), result.getNextAfter());
+ return result;
+ }
+
+ /**
+ * 단일 기사 상세 조회
+ */
+ public ArticleDto findArticle(Long articleId, Long userId) {
+ log.info("[기사 상세 조회 시도] 기사 ID: {}, 사용자 ID: {}", articleId, userId);
+
+ Article article = articleRepository.findByIdAndIsDeletedFalse(articleId)
+ .orElseThrow(() -> {
+ log.warn("[기사 상세 조회 실패] 존재하지 않는 기사: {}", articleId);
+ return new ArticleNotFoundException();
+ });
+
+ boolean viewedByMe = articleViewRepository.existsByUserIdAndArticleId(userId, articleId);
+ log.debug("[기사 상세 조회 성공] 기사 ID: {}, 사용자 ID: {}, 조회 여부: {}", articleId, userId, viewedByMe);
+
+ return ArticleDto.builder()
+ .id(article.getId())
+ .source(article.getSource())
+ .sourceUrl(article.getSourceUrl())
+ .title(article.getTitle())
+ .publishDate(article.getPublishDate())
+ .summary(article.getSummary())
+ .viewCount(article.getViewCount())
+ .viewedByMe(viewedByMe)
+ .build();
+ }
+
+ /**
+ * 전체 뉴스 소스 목록 조회
+ */
+ public List getAllSources() {
+ log.info("[뉴스 출처 목록 조회]");
+ List sources = articleRepository.findDistinctSources();
+
+ sources.sort((a, b) -> {
+ if (a.equalsIgnoreCase("Naver")) return -1;
+ if (b.equalsIgnoreCase("Naver")) return 1;
+ return a.compareToIgnoreCase(b);
+ });
+
+ log.debug("[뉴스 출처 조회 완료] 출처 개수: {}, 정렬 결과: {}", sources.size(), sources);
+ return sources;
+ }
+
+ /**
+ * 기사 논리 삭제
+ */
+ @Transactional
+ public void softDeleteArticle(Long articleId) {
+ log.info("[기사 논리 삭제 시도] 기사 ID: {}", articleId);
+
+ Article article = articleRepository.findByIdAndIsDeletedFalse(articleId)
+ .orElseThrow(() -> {
+ log.warn("[논리 삭제 실패] 존재하지 않는 기사: {}", articleId);
+ return new ArticleNotFoundException();
+ });
+
+ article.softDelete();
+ log.info("[논리 삭제 성공] 기사 ID: {}", articleId);
+ }
+
+ /**
+ * 기사 영구 삭제
+ */
+ @Transactional
+ public void hardDeleteArticle(Long articleId) {
+ log.info("[기사 영구 삭제 시도] 기사 ID: {}", articleId);
+
+ articleRepository.deleteById(articleId);
+ log.warn("[기사 영구 삭제 완료] 기사 ID: {}", articleId);
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/article/service/NewsRestoreService.java b/monew-api/src/main/java/com/monew/monew_api/article/service/NewsRestoreService.java
new file mode 100644
index 0000000..a9c6284
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/article/service/NewsRestoreService.java
@@ -0,0 +1,170 @@
+package com.monew.monew_api.article.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.monew.monew_api.article.dto.ArticleBackupData;
+import com.monew.monew_api.article.entity.Article;
+import com.monew.monew_api.article.repository.*;
+import com.monew.monew_api.common.entity.BaseIdEntity;
+import com.monew.monew_api.common.exception.article.ArticleNotFoundException;
+import com.monew.monew_api.interest.entity.Interest;
+import com.monew.monew_api.interest.repository.InterestRepository;
+import com.monew.monew_api.interest.repository.KeywordRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.S3Object;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class NewsRestoreService {
+
+ private final S3Client s3Client;
+ private final ObjectMapper objectMapper;
+ private final ArticleRepository articleRepository;
+ private final ArticleJdbcRepository articleJdbcRepository;
+ private final KeywordRepository keywordRepository;
+ private final InterestRepository interestRepository;
+ private final InterestArticlesRepository interestArticlesRepository;
+ private final InterestArticleKeywordRepository interestArticleKeywordRepository;
+
+ @Value("${aws.bucket}")
+ private String bucketName;
+
+ private static final String PREFIX = "backup/article_backup_";
+ private static final DateTimeFormatter FILE_DATE_FORMAT =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss");
+
+ /** 메인 진입점 */
+ @Transactional
+ public void restoreArticles(LocalDateTime from, LocalDateTime to) {
+ long start = System.currentTimeMillis();
+ log.info("🗃 복원 시작: {} ~ {}", from, to);
+
+ try {
+ // 1. S3에서 파일 목록 가져오기
+ List fileKeys = getBackupFileKeys(from, to);
+ if (fileKeys.isEmpty()) return;
+
+ // 2. 여러 백업 파일 병합
+ List mergedArticles = mergeBackupData(fileKeys);
+ if (mergedArticles.isEmpty()) return;
+
+ // 3. 이미 존재하는 기사 제외
+ List newArticles = filterExistingArticles(mergedArticles);
+ if (newArticles.isEmpty()) return;
+
+ log.info("📰 신규 기사 {}건 복원 시도", newArticles.size());
+
+ // 4. 기사 단위 복원
+ int restored = 0, skipped = 0;
+
+ for (ArticleBackupData.ArticleData data : newArticles) {
+ boolean success = restoreSingleArticleExact(data);
+ if (success) restored++;
+ else skipped++;
+ }
+
+ log.info("✅ 복원 완료 | 성공: {}건, 스킵: {}건", restored, skipped);
+
+ } finally {
+ long end = System.currentTimeMillis();
+ log.info("⏰ 복원 종료: 총 {}초 소요", (end - start) / 1000.0);
+ }
+ }
+
+ /** 지정된 기간의 S3 백업 파일 목록 조회 */
+ private List getBackupFileKeys(LocalDateTime from, LocalDateTime to) {
+ List keys = s3Client.listObjectsV2(b -> b.bucket(bucketName).prefix("backup/"))
+ .contents().stream()
+ .map(S3Object::key)
+ .filter(k -> k.startsWith(PREFIX))
+ .filter(k -> {
+ LocalDateTime date = parseDateFromKey(k);
+ return !date.isBefore(from) && !date.isAfter(to);
+ })
+ .toList();
+
+ if (keys.isEmpty()) log.info("📂 복원할 백업 파일이 없습니다.");
+ return keys;
+ }
+
+ /** 파일명에서 날짜 추출 */
+ private LocalDateTime parseDateFromKey(String key) {
+ try {
+ String datePart = key.replace(PREFIX, "").replace(".json", "");
+ return LocalDateTime.parse(datePart, FILE_DATE_FORMAT);
+ } catch (Exception e) {
+ return LocalDateTime.MIN;
+ }
+ }
+
+ /** 여러 백업 파일 병합 */
+ private List mergeBackupData(List keys) {
+ Map merged = new LinkedHashMap<>();
+
+ for (String key : keys) {
+ try {
+ String json = s3Client.getObjectAsBytes(b -> b.bucket(bucketName).key(key)).asUtf8String();
+ ArticleBackupData backup = objectMapper.readValue(json, ArticleBackupData.class);
+ backup.getArticles().forEach(a -> merged.putIfAbsent(a.getSourceUrl(), a));
+ } catch (Exception e) {
+ log.error("⚠️ 백업 파일 로드 실패: {}", key, e);
+ }
+ }
+
+ if (merged.isEmpty()) log.info("📄 병합된 복원 대상이 없습니다.");
+ return new ArrayList<>(merged.values());
+ }
+
+ /** 이미 존재하는 기사 제외 */
+ private List filterExistingArticles(List articles) {
+ Set existingUrls = articleRepository.findAllSourceUrls();
+ return articles.stream()
+ .filter(a -> !existingUrls.contains(a.getSourceUrl()))
+ .toList();
+ }
+
+ /** 기사 복원 (Writer 시점과 동일하되 insertIgnore 적용) */
+ private boolean restoreSingleArticleExact(ArticleBackupData.ArticleData data) {
+ try {
+ boolean inserted = articleJdbcRepository.insertIgnore(data.toEntity());
+ if (!inserted) return false;
+
+ // insertIgnore은 영속성 컨텍스으에 반영안됌 -> id로 조회 못함
+ Article article = articleRepository.findBySourceUrl(data.getSourceUrl())
+ .orElseThrow(ArticleNotFoundException::new);
+
+ List keywords = data.getKeywords();
+ for (String keywordName : keywords) {
+ keywordRepository.findByKeyword(keywordName).ifPresent(keyword -> {
+ List interests = interestRepository.findAllByKeyword(keyword);
+ for (Interest interest : interests) {
+ int result = interestArticlesRepository.insertIgnore(interest.getId(), article.getId());
+ if (result > 0) {
+ log.info("🔗 [{}] 관심사-기사 연결 완료: {}", interest.getName(), article.getTitle());
+ }
+
+ interestArticleKeywordRepository.insertIgnore(
+ interestArticlesRepository.findByArticleAndInterest(article, interest)
+ .map(BaseIdEntity::getId)
+ .orElseThrow(),
+ keyword.getId()
+ );
+ }
+ });
+ }
+ return true;
+ } catch (Exception e) {
+ log.error("⚠️ 기사 [{}] 복원 실패: {}", data.getTitle(), e.getMessage());
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java b/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java
new file mode 100644
index 0000000..9a632e0
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java
@@ -0,0 +1,122 @@
+package com.monew.monew_api.comments.controller;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.monew.monew_api.comments.dto.CommentDto;
+import com.monew.monew_api.comments.dto.CommentLikeDto;
+import com.monew.monew_api.comments.dto.CommentRegisterRequest;
+import com.monew.monew_api.comments.dto.CommentSearchRequest;
+import com.monew.monew_api.comments.dto.CommentUpdateRequest;
+import com.monew.monew_api.comments.dto.CursorPageResponseCommentDto;
+import com.monew.monew_api.comments.service.CommentService;
+
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RestController
+@RequestMapping("/api/comments")
+@RequiredArgsConstructor
+@Validated
+public class CommentController {
+
+ private static final String REQUEST_HEADER_USER_ID = "MoNew-Request-User-ID";
+ private final CommentService commentService;
+
+ // 댓글 조회
+ @GetMapping
+ public ResponseEntity findAll(
+ @RequestHeader(REQUEST_HEADER_USER_ID) Long userId,
+ @ModelAttribute CommentSearchRequest request
+ ) {
+ log.info("[CommentController] GET /api/comments - userId={}, request={}", userId, request);
+ CursorPageResponseCommentDto response = commentService.findAll(userId, request);
+ return ResponseEntity.ok(response);
+ }
+
+ // 댓글 작성
+ @PostMapping
+ public ResponseEntity register(
+ @Valid @RequestBody CommentRegisterRequest request
+ ) {
+ log.info("[CommentController] POST /api/comments - register request={}", request);
+ CommentDto dto = commentService.register(request);
+ log.info("[CommentController] POST /api/comments - created commentId={}", dto.id());
+ return ResponseEntity.status(HttpStatus.CREATED).body(dto);
+ }
+
+ // 댓글 수정
+ @PatchMapping("/{commentId}")
+ public ResponseEntity update(
+ @RequestHeader(REQUEST_HEADER_USER_ID) Long userId,
+ @PathVariable Long commentId,
+ @Valid @RequestBody CommentUpdateRequest request
+ ) {
+ log.info("[CommentController] PATCH /api/comments/{} - userId={}, request={}", commentId, userId, request);
+ CommentDto dto = commentService.update(userId, commentId, request);
+ log.info("[CommentController] PATCH /api/comments/{} - updated", commentId);
+ return ResponseEntity.ok(dto);
+ }
+
+ // 댓글 좋아요
+ @PostMapping("/{commentId}/comment-likes")
+ public ResponseEntity like(
+ @RequestHeader(REQUEST_HEADER_USER_ID) Long userId,
+ @PathVariable Long commentId
+ ) {
+ log.info("[CommentController] POST /api/comments/{}/comment-likes - like request, userId={}"
+ , commentId, userId);
+ CommentLikeDto dto = commentService.like(userId, commentId);
+ log.info("[CommentController] POST /api/comments/{}/comment-likes - like success, likeId={}"
+ , commentId, dto.id());
+ return ResponseEntity.ok(dto);
+ }
+
+ // 댓글 좋아요 삭제
+ @DeleteMapping("/{commentId}/comment-likes")
+ public ResponseEntity dislike(
+ @RequestHeader(REQUEST_HEADER_USER_ID) Long userId,
+ @PathVariable Long commentId
+ ) {
+ log.info("[CommentController] DELETE /api/comments/{}/comment-likes - dislike request, userId={}"
+ , commentId, userId);
+ commentService.dislike(userId, commentId);
+ log.info("[CommentController] DELETE /api/comments/{}/comment-likes - dislike success"
+ , commentId);
+ return ResponseEntity.noContent().build();
+ }
+
+ // 댓글 논리 삭제
+ @DeleteMapping("/{commentId}")
+ public ResponseEntity delete(
+ @PathVariable Long commentId
+ ) {
+ log.info("[CommentController] DELETE /api/comments/{} - soft delete request", commentId);
+ commentService.delete(commentId);
+ log.info("[CommentController] DELETE /api/comments/{} - soft delete success", commentId);
+ return ResponseEntity.noContent().build();
+ }
+
+ // 댓글 물리 삭제
+ @DeleteMapping("/{commentId}/hard")
+ public ResponseEntity hardDelete(
+ @PathVariable Long commentId) {
+ log.info("[CommentController] DELETE /api/comments/{}/hard - hard delete request", commentId);
+ commentService.hardDelete(commentId);
+ log.info("[CommentController] DELETE /api/comments/{}/hard - hard delete success", commentId);
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java
new file mode 100644
index 0000000..5a37d4c
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java
@@ -0,0 +1,32 @@
+package com.monew.monew_api.comments.dto;
+
+import com.monew.monew_api.comments.entity.Comment;
+import com.querydsl.core.annotations.QueryProjection;
+
+public record CommentDto(
+ Long id,
+ Long userId,
+ Long articleId,
+ String userNickname,
+ String content,
+ int likeCount,
+ boolean likedByMe,
+ String createdAt
+) {
+ @QueryProjection // QCommentDto를 생성
+ public CommentDto {
+ }
+ public static CommentDto from(Comment comment, boolean likedByMe) {
+ return new CommentDto(
+ comment.getId(),
+ comment.getUser().getId(),
+ comment.getArticle().getId(),
+ comment.getUser().getNickname(),
+ comment.getContent(),
+ comment.getLikeCount(),
+ likedByMe,
+ comment.getCreatedAt().toString()
+ );
+ }
+
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java
new file mode 100644
index 0000000..abf9b20
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java
@@ -0,0 +1,34 @@
+package com.monew.monew_api.comments.dto;
+
+import java.time.LocalDateTime;
+
+import com.monew.monew_api.comments.entity.CommentLike;
+
+public record CommentLikeDto(
+ Long id,
+ Long commentId,
+ Long articleId,
+ Long likedBy,
+ Long commentUserId,
+ String commentUserNickname,
+ String commentContent,
+ int commentLikeCount,
+ String commentCreatedAt,
+ String createdAt
+) {
+
+ public static CommentLikeDto from(CommentLike like) {
+ return new CommentLikeDto(
+ like.getId(),
+ like.getComment().getId(),
+ like.getComment().getArticleId(),
+ like.getUser().getId(),
+ like.getComment().getUserId(),
+ like.getComment().getUser().getNickname(),
+ like.getComment().getContent(),
+ like.getComment().getLikeCount(),
+ like.getComment().getCreatedAt().toString(),
+ like.getCreatedAt().toString()
+ );
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java
new file mode 100644
index 0000000..3a0b76a
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java
@@ -0,0 +1,18 @@
+package com.monew.monew_api.comments.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+
+public record CommentRegisterRequest(
+ @NotNull(message = "기사 ID는 필수입니다.")
+ Long articleId,
+
+ @NotNull(message = "유저 ID는 필수입니다.")
+ Long userId,
+
+ @NotBlank(message = "댓글 내용을 입력해주세요.")
+ @Size(max = 500, message = "댓글은 최대 500자까지 작성 가능합니다.")
+ String content
+) {
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentSearchRequest.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentSearchRequest.java
new file mode 100644
index 0000000..de1374f
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentSearchRequest.java
@@ -0,0 +1,33 @@
+package com.monew.monew_api.comments.dto;
+
+import java.time.LocalDateTime;
+
+import org.springframework.format.annotation.DateTimeFormat;
+
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.Pattern;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class CommentSearchRequest {
+
+ private Long articleId;
+
+ @Pattern(regexp = "createdAt|likeCount", message = "orderBy는 'createdAt' 또는 'likeCount'만 가능합니다.")
+ private String orderBy = "createdAt";
+
+ private String direction = "DESC";
+
+ private String cursor;
+
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
+ private LocalDateTime after;
+
+ @Min(value = 1, message = "limit은 1 이상이어야 합니다.")
+ @Max(value = 50, message = "limit은 최대 50까지만 가능합니다.")
+ private int limit = 10;
+
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentUpdateRequest.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentUpdateRequest.java
new file mode 100644
index 0000000..744ba53
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentUpdateRequest.java
@@ -0,0 +1,11 @@
+package com.monew.monew_api.comments.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public record CommentUpdateRequest(
+ @NotBlank(message = "댓글 내용을 입력해주세요.")
+ @Size(max = 500, message = "댓글은 최대 500자까지 작성 가능합니다.")
+ String content
+) {
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CursorPageResponseCommentDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CursorPageResponseCommentDto.java
new file mode 100644
index 0000000..8216b1b
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CursorPageResponseCommentDto.java
@@ -0,0 +1,14 @@
+package com.monew.monew_api.comments.dto;
+
+import java.time.ZonedDateTime;
+import java.util.List;
+
+public record CursorPageResponseCommentDto(
+ List content,
+ String nextCursor,
+ ZonedDateTime nextAfter,
+ int size,
+ long totalElements,
+ boolean hasNext
+) {
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java b/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java
new file mode 100644
index 0000000..5a8889e
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java
@@ -0,0 +1,89 @@
+package com.monew.monew_api.comments.entity;
+
+import org.hibernate.annotations.SQLDelete;
+import org.hibernate.annotations.Where;
+
+import com.monew.monew_api.article.entity.Article;
+import com.monew.monew_api.common.entity.BaseTimeEntity;
+import com.monew.monew_api.user.User;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.Index;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+@Table(
+ name = "comments",
+ indexes = {
+ @Index(name = "ix_comments_user", columnList = "user_id"),
+ @Index(name = "ix_comments_article", columnList = "article_id")
+ }
+)
+@SQLDelete(sql = "UPDATE comments SET is_deleted = true, updated_at = now() WHERE id = ?")
+@Where(clause = "is_deleted = false")
+public class Comment extends BaseTimeEntity {
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User user;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "article_id", nullable = false)
+ private Article article;
+
+ @Size(max = 500)
+ @NotBlank
+ @Column(name = "content", nullable = false, length = 500)
+ private String content;
+
+ @Column(name = "is_deleted", nullable = false)
+ private boolean deleted = false;
+
+ @Column(name = "like_count", nullable = false)
+ private int likeCount = 0;
+
+ private Comment(User user, Article article, String content) {
+ this.user = user;
+ this.article = article;
+ this.content = content;
+ }
+
+ public static Comment of(User user, Article article, String content) {
+ return new Comment(user, article, content);
+ }
+
+ public void updateContent(String content) {
+ this.content = content;
+ }
+
+ public void increaseLike() {
+ this.likeCount++;
+ }
+
+ public void decreaseLike() {
+ if (this.likeCount > 0) this.likeCount--;
+ }
+
+ public boolean isOwnedBy(Long userId) {
+ return this.user != null && this.user.getId().equals(userId);
+ }
+
+ public Long getUserId() {
+ return user != null ? user.getId() : null;
+ }
+
+ public Long getArticleId() {
+ return article != null ? article.getId() : null;
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java b/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java
new file mode 100644
index 0000000..4312e7c
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java
@@ -0,0 +1,57 @@
+package com.monew.monew_api.comments.entity;
+
+import com.monew.monew_api.common.entity.BaseCreatedEntity;
+import com.monew.monew_api.user.User;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.Index;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+@Table(
+ name = "comment_likes",
+ uniqueConstraints = {
+ @UniqueConstraint(name = "uq_comment_likes", columnNames = {"user_id", "comment_id"})
+ },
+ indexes = {
+ @Index(name = "ix_comment_likes_user", columnList = "user_id"),
+ @Index(name = "ix_comment_likes_comment", columnList = "comment_id")
+ }
+
+)
+public class CommentLike extends BaseCreatedEntity {
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name="user_id", nullable=false)
+ private User user;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name="comment_id", nullable=false)
+ private Comment comment;
+
+ private CommentLike(User user, Comment comment) {
+ this.user = user;
+ this.comment = comment;
+ }
+
+ public static CommentLike of(User user, Comment comment) {
+ return new CommentLike(user, comment);
+ }
+
+ public boolean isByUser(Long userId) {
+ return user != null && user.getId().equals(userId);
+ }
+
+ public boolean isForComment(Long commentId) {
+ return comment != null && comment.getId().equals(commentId);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentContentEditedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentContentEditedEvent.java
new file mode 100644
index 0000000..bfe89bc
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentContentEditedEvent.java
@@ -0,0 +1,13 @@
+package com.monew.monew_api.comments.event;
+
+import java.time.LocalDateTime;
+
+public record CommentContentEditedEvent(
+ Long commentId,
+ String newContent,
+ LocalDateTime occurredAt
+) {
+ public static CommentContentEditedEvent of(Long commentId, String newContent) {
+ return new CommentContentEditedEvent(commentId, newContent, LocalDateTime.now());
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java
new file mode 100644
index 0000000..4705aa3
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java
@@ -0,0 +1,58 @@
+package com.monew.monew_api.comments.event;
+
+import java.time.LocalDateTime;
+
+/**
+ * 댓글 작성 이벤트
+ * 사용자가 기사에 댓글을 작성했을 때 발행
+ * 기사의 commentCount +1
+ * Update 전략 사용
+ * @param commentId
+ * @param articleId
+ * @param articleTitle
+ * @param userId
+ * @param userNickname
+ * @param content
+ * @param likeCount
+ * @param createdAt
+ * @param occurredAt
+ */
+public record CommentCreatedEvent(
+ Long commentId,
+ Long articleId,
+ String articleTitle,
+ Long userId,
+ String userNickname,
+ String content,
+ Integer likeCount,
+ LocalDateTime createdAt,
+ LocalDateTime occurredAt
+) {
+
+ public static CommentCreatedEvent of(
+ Long commentId,
+ Long articleId,
+ String articleTitle,
+ Long userId,
+ String userNickname,
+ String content,
+ Integer likeCount,
+ LocalDateTime createdAt
+ ) {
+ return new CommentCreatedEvent(
+ commentId,
+ articleId,
+ articleTitle,
+ userId,
+ userNickname,
+ content,
+ likeCount,
+ createdAt,
+ LocalDateTime.now()
+ );
+ }
+
+ public Integer getDelta() {
+ return 1;
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentDeletedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentDeletedEvent.java
new file mode 100644
index 0000000..260a045
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentDeletedEvent.java
@@ -0,0 +1,18 @@
+package com.monew.monew_api.comments.event;
+
+import java.time.LocalDateTime;
+
+/**
+ * 댓글 삭제 이벤트
+ * 댓글이 삭제되었을 때 발행
+ * @param commentId
+ * @param occurredAt
+ */
+public record CommentDeletedEvent(
+ Long commentId,
+ LocalDateTime occurredAt
+) {
+ public static CommentDeletedEvent of(Long commentId) {
+ return new CommentDeletedEvent(commentId, LocalDateTime.now());
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java
new file mode 100644
index 0000000..1fd8f3a
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java
@@ -0,0 +1,59 @@
+package com.monew.monew_api.comments.event;
+
+import java.time.LocalDateTime;
+
+/**
+ * 댓글 좋아요/취소 이벤트
+ * 사용자가 댓글에 좋아요를 누르거나 취소했을 때 발행
+ * Update 전략 사용
+ * @param likeId
+ * @param likeCreatedAt
+ * @param commentId
+ * @param articleId
+ * @param articleTitle
+ * @param commentAuthorId
+ * @param commentUserNickname
+ * @param commentContent
+ * @param commentLikeCount
+ * @param commentCreatedAt
+ * @param likedByUserId
+ * @param likerNickname
+ * @param occurredAt
+ */
+public record CommentLikedEvent(
+ Long likeId,
+ LocalDateTime likeCreatedAt,
+ Long commentId,
+ Long articleId,
+ String articleTitle,
+ Long commentAuthorId,
+ String commentUserNickname,
+ String commentContent,
+ Integer commentLikeCount,
+ LocalDateTime commentCreatedAt,
+ Long likedByUserId,
+ String likerNickname,
+ LocalDateTime occurredAt
+) {
+ public static CommentLikedEvent of(
+ Long likeId,
+ LocalDateTime likeCreatedAt,
+ Long commentId,
+ Long articleId,
+ String articleTitle,
+ Long commentAuthorId,
+ String commentUserNickname,
+ String commentContent,
+ Integer commentLikeCount,
+ LocalDateTime commentCreatedAt,
+ Long likedByUserId,
+ String likerNickname
+ ) {
+ return new CommentLikedEvent(
+ likeId, likeCreatedAt, commentId, articleId, articleTitle,
+ commentAuthorId, commentUserNickname, commentContent,
+ commentLikeCount, commentCreatedAt, likedByUserId, likerNickname,
+ LocalDateTime.now()
+ );
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentUnlikedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentUnlikedEvent.java
new file mode 100644
index 0000000..9d0a731
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentUnlikedEvent.java
@@ -0,0 +1,20 @@
+package com.monew.monew_api.comments.event;
+
+import java.time.LocalDateTime;
+
+/**
+ * 댓글 좋아요 취소 이벤트
+ * 사용자가 댓글 좋아요를 취소했을 때 발행
+ * @param commentId
+ * @param likedByUserId
+ * @param occurredAt
+ */
+public record CommentUnlikedEvent(
+ Long commentId,
+ Long likedByUserId,
+ LocalDateTime occurredAt
+) {
+ public static CommentUnlikedEvent of(Long commentId, Long likedByUserId) {
+ return new CommentUnlikedEvent(commentId, likedByUserId, LocalDateTime.now());
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java
new file mode 100644
index 0000000..544a1e1
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java
@@ -0,0 +1,18 @@
+package com.monew.monew_api.comments.repository;
+
+import java.util.Collection;
+import java.util.List;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import com.monew.monew_api.comments.entity.CommentLike;
+
+public interface CommentLikeRepository extends JpaRepository {
+
+ // 좋아요 중복 확인
+ boolean existsByComment_IdAndUser_Id(Long commentId, Long userId);
+
+ // 자신 좋아요 취소
+ void deleteByComment_IdAndUser_Id(Long commentId, Long userId);
+
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java
new file mode 100644
index 0000000..6c7fb9a
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java
@@ -0,0 +1,25 @@
+package com.monew.monew_api.comments.repository;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import com.monew.monew_api.comments.entity.Comment;
+
+public interface CommentRepository extends JpaRepository, CommentRepositoryCustom {
+
+ // 좋아요 취소
+ @Modifying(clearAutomatically = true, flushAutomatically = true)
+ @Query("""
+ update Comment c
+ set c.likeCount = case when c.likeCount > 0 then c.likeCount - 1 else 0 end
+ where c.id = :id
+ """)
+ int decLikeCount(@Param("id") Long id);
+
+ // 댓글 물리 삭제
+ @Modifying
+ @Query(value = "DELETE FROM comments WHERE id = :id AND is_deleted = true", nativeQuery = true)
+ int hardDeleteById(@Param("id") Long id);
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepositoryCustom.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepositoryCustom.java
new file mode 100644
index 0000000..2a507f8
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepositoryCustom.java
@@ -0,0 +1,18 @@
+package com.monew.monew_api.comments.repository;
+
+import java.time.LocalDateTime;
+
+import com.monew.monew_api.comments.dto.CursorPageResponseCommentDto;
+
+public interface CommentRepositoryCustom {
+
+ // 댓글 조회
+ CursorPageResponseCommentDto searchComments(
+ Long articleId,
+ String orderBy,
+ String cursor,
+ LocalDateTime after,
+ int limit,
+ Long userId
+ );
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java
new file mode 100644
index 0000000..61c23dc
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java
@@ -0,0 +1,191 @@
+package com.monew.monew_api.comments.repository.impl;
+
+import static com.monew.monew_api.comments.entity.QComment.*;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.List;
+
+import org.springframework.stereotype.Repository;
+
+import com.monew.monew_api.comments.dto.CommentDto;
+import com.monew.monew_api.comments.dto.CursorPageResponseCommentDto;
+import com.monew.monew_api.comments.dto.QCommentDto;
+import com.monew.monew_api.comments.entity.QComment;
+import com.monew.monew_api.comments.entity.QCommentLike;
+import com.monew.monew_api.comments.repository.CommentRepositoryCustom;
+import com.querydsl.core.types.OrderSpecifier;
+import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.jpa.JPAExpressions;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@Repository
+@RequiredArgsConstructor
+public class CommentRepositoryImpl implements CommentRepositoryCustom {
+
+ private final JPAQueryFactory jpaQueryFactory;
+
+ @Override
+ public CursorPageResponseCommentDto searchComments(
+ Long articleId, String orderBy, String cursor,
+ LocalDateTime after, int limit, Long userId) {
+
+ QCommentLike cl = QCommentLike.commentLike;
+
+ // DTO로 바로 조회 (서브쿼리로 좋아요 여부 포함)
+ List comments = jpaQueryFactory
+ .select(new QCommentDto(
+ comment.id,
+ comment.user.id,
+ comment.article.id,
+ comment.user.nickname,
+ comment.content,
+ comment.likeCount,
+ JPAExpressions // 좋아요 여부 확인하는 서브쿼리
+ .selectOne()
+ .from(cl)
+ .where(
+ cl.comment.id.eq(comment.id)
+ .and(cl.user.id.eq(userId))
+ )
+ .exists(),
+ comment.createdAt.stringValue()
+ ))
+ .from(comment)
+ .where(
+ articleIdEq(articleId),
+ cursorCondition(orderBy, cursor, after)
+ )
+ .orderBy(orderSpecifiers(comment, orderBy))
+ .limit(limit + 1L)
+ .fetch();
+
+ log.info("[조회 결과] 총 {}개 댓글 조회됨", comments.size());
+
+ // hasNext 계산
+ boolean hasNext = comments.size() > limit;
+ if (hasNext) {
+ comments.remove(limit);
+ }
+
+ // nextCursor, nextAfter 생성
+ String nextCursor = null;
+ ZonedDateTime nextAfter = null;
+
+ if (hasNext && !comments.isEmpty()) {
+ CommentDto last = comments.get(comments.size() - 1);
+
+ if ("likeCount".equalsIgnoreCase(orderBy)) {
+ nextCursor = last.likeCount() + ":" + last.id();
+ } else {
+ nextCursor = String.valueOf(last.id());
+ }
+
+ // after는 입력받은 값을 그대로 유지 (시간 필터 고정)
+ nextAfter = after != null ? after.atZone(ZoneId.systemDefault()) : null;
+
+ }
+
+ return new CursorPageResponseCommentDto(
+ comments,
+ nextCursor,
+ nextAfter,
+ comments.size(),
+ -1L,
+ hasNext
+ );
+ }
+
+ // 커서 조건 분배(최신순, 인기순)
+ private BooleanExpression cursorCondition(String orderBy, String cursor, LocalDateTime after) {
+ if ("likeCount".equalsIgnoreCase(orderBy)) {
+ return buildLikeCountCursor(cursor, after);
+ } else {
+ return buildCreatedAtCursor(cursor, after);
+ }
+ }
+
+ // createdAt 기준 커서 조건 (최신순)
+ private BooleanExpression buildCreatedAtCursor(
+ String cursor, LocalDateTime after
+ ) {
+ Long cursorId = parseLongCursor(cursor);
+ // 둘 다 없으면 조건 없음
+ if (cursorId == null && after == null) {
+ return null;
+ }
+
+ // cursor만 있을 때
+ if (cursorId != null && after == null) {
+ return comment.id.lt(cursorId);
+ }
+
+ // after만 있을 때
+ if (cursorId == null) {
+ return comment.createdAt.lt(after);
+ }
+
+ // 둘 다 있을 때
+ return comment.createdAt.lt(after)
+ .or(
+ comment.createdAt.eq(after)
+ .and(comment.id.lt(cursorId))
+ );
+ }
+
+ // likeCount 기준 커서 조건
+ private BooleanExpression buildLikeCountCursor(String cursor, LocalDateTime after) {
+
+ if (cursor == null || cursor.isBlank()) {
+ return null;
+ }
+
+ String[] parts = cursor.split(":");
+ if (parts.length != 2) {
+ return null;
+ }
+
+ try {
+ Integer likeCount = Integer.parseInt(parts[0]);
+ Long id = Long.parseLong(parts[1]);
+
+ return comment.likeCount.lt(likeCount)
+ .or(
+ comment.likeCount.eq(likeCount)
+ .and(comment.id.lt(id))
+ );
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ // Long 타입 커서 파싱
+ private Long parseLongCursor(String cursor) {
+ if (cursor == null || cursor.isBlank()) {
+ return null;
+ }
+ try {
+ return Long.parseLong(cursor);
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ // 게시글 필터 조건
+ private BooleanExpression articleIdEq(Long articleId) {
+ return articleId == null ? null : comment.article.id.eq(articleId);
+ }
+
+ // 정렬 컬럼 선택
+ private OrderSpecifier>[] orderSpecifiers(QComment c, String orderBy) {
+ if ("likeCount".equalsIgnoreCase(orderBy)) {
+ return new OrderSpecifier[] {c.likeCount.desc(), c.id.desc()};
+ }
+ return new OrderSpecifier[] {c.createdAt.desc(), c.id.desc()};
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java
new file mode 100644
index 0000000..db96bc2
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java
@@ -0,0 +1,201 @@
+package com.monew.monew_api.comments.service;
+
+import com.monew.monew_api.article.entity.Article;
+import com.monew.monew_api.article.repository.ArticleRepository;
+import com.monew.monew_api.comments.dto.*;
+import com.monew.monew_api.comments.entity.Comment;
+import com.monew.monew_api.comments.entity.CommentLike;
+import com.monew.monew_api.comments.event.*;
+import com.monew.monew_api.comments.repository.CommentLikeRepository;
+import com.monew.monew_api.comments.repository.CommentRepository;
+import com.monew.monew_api.common.exception.comment.*;
+import com.monew.monew_api.user.User;
+import com.monew.monew_api.user.repository.UserRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class CommentService {
+
+ private final CommentRepository commentRepository;
+ private final CommentLikeRepository commentLikeRepository;
+ private final UserRepository userRepository;
+ private final ArticleRepository articleRepository;
+ private final ApplicationEventPublisher eventPublisher;
+
+ // 댓글 작성
+ @Transactional
+ public CommentDto register(CommentRegisterRequest request) {
+ log.info("[COMMENT][CREATE][START] userId={}, articleId={}", request.userId(), request.articleId());
+ User user = getUserById(request.userId());
+ Article article = getArticleById(request.articleId());
+
+ log.info("[COMMENT_COUNT] 댓글 작성 전 카운트: {}", article.getCommentCount());
+ Comment saved = commentRepository.save(Comment.of(user, article, request.content()));
+ log.info("[COMMENT][CREATE] userId={}, articleId={}, commentId={}",
+ user.getId(), article.getId(), saved.getId());
+
+ article.increaseCommentCount();
+ articleRepository.save(article);
+
+ eventPublisher.publishEvent(
+ CommentCreatedEvent.of(
+ saved.getId(),
+ saved.getArticleId(),
+ article.getTitle(),
+ saved.getUserId(),
+ user.getNickname(),
+ saved.getContent(),
+ saved.getLikeCount(),
+ saved.getCreatedAt())
+ );
+
+ log.info("[COMMENT_COUNT] 댓글 작성 후 카운트: {}", article.getCommentCount());
+ return CommentDto.from(saved, false);
+ }
+
+ // 댓글 수정
+ @Transactional
+ public CommentDto update(Long userId, Long commentId, CommentUpdateRequest request) {
+ log.info("[COMMENT][UPDATE][START] userId={}, commentId={}", userId, commentId);
+ Comment comment = getCommentById(commentId);
+ validateOwnership(comment, userId);
+
+ comment.updateContent(request.content());
+ log.info("[COMMENT][UPDATE] userId={}, commentId={}, contentLength={}",
+ userId, commentId, request.content().length());
+
+ boolean likedByMe = commentLikeRepository.existsByComment_IdAndUser_Id(commentId, userId);
+
+ eventPublisher.publishEvent(CommentContentEditedEvent.of(commentId, request.content()));
+
+ return CommentDto.from(comment, likedByMe);
+ }
+
+ // 댓글 좋아요
+ @Transactional
+ public CommentLikeDto like(Long userId, Long commentId) {
+ log.info("[COMMENT][LIKE] 좋아요 요청 시작 - userId={}, commentId={}", userId, commentId);
+ User user = getUserById(userId);
+ Comment comment = getCommentById(commentId);
+ log.info("[COMMENT][LIKE] 엔티티 조회 완료 - user={}, comment={}", user.getId(), comment.getId());
+ CommentLike saved = commentLikeRepository.save(CommentLike.of(user, comment));
+
+ eventPublisher.publishEvent(
+ CommentLikedEvent.of(
+ saved.getId(),
+ saved.getCreatedAt(),
+ commentId,
+ comment.getArticle().getId(),
+ comment.getArticle().getTitle(),
+ comment.getUserId(),
+ comment.getUser().getNickname(),
+ comment.getContent(),
+ comment.getLikeCount(),
+ comment.getCreatedAt(),
+ user.getId(),
+ user.getNickname()));
+
+ comment.increaseLike();
+ log.info("[COMMENT][LIKE] userId={}, commentId={}", userId, commentId);
+ return CommentLikeDto.from(saved);
+ }
+
+ // 댓글 좋아요 삭제
+ @Transactional
+ public void dislike(Long userId, Long commentId) {
+ log.info("[COMMENT][DISLIKE][START] userId={}, commentId={}", userId, commentId);
+ boolean liked = commentLikeRepository.existsByComment_IdAndUser_Id(commentId, userId);
+ if (!liked)
+ throw new CommentNotLikedException();
+
+ commentLikeRepository.deleteByComment_IdAndUser_Id(commentId, userId);
+ commentRepository.decLikeCount(commentId);
+
+ eventPublisher.publishEvent(CommentUnlikedEvent.of(commentId, userId));
+
+ log.info("[COMMENT][DISLIKE] userId={}, commentId={}", userId, commentId);
+ }
+
+ // 댓글 논리 삭제
+ @Transactional
+ public void delete(Long commentId) {
+ log.info("[COMMENT][DELETE][START] commentId={}", commentId);
+ Comment comment = getCommentById(commentId);
+
+ Article article = comment.getArticle();
+ log.info("[COMMENT_COUNT] 댓글 삭제 전 카운트: {}", article.getCommentCount());
+
+ commentRepository.delete(comment);
+
+ article.decreaseCommentCount();
+ articleRepository.save(article);
+ eventPublisher.publishEvent(CommentDeletedEvent.of(commentId));
+ log.info("[COMMENT_COUNT] 댓글 삭제 후 카운트: {}", article.getCommentCount());
+ log.info("[COMMENT][DELETE] commentId={}", commentId);
+ }
+
+ // 댓글 물리 삭제
+ @Transactional
+ public void hardDelete(Long commentId) {
+ log.info("[COMMENT][HARD_DELETE][START] commentId={}", commentId);
+ int deletedCount = commentRepository.hardDeleteById(commentId);
+ // 0 = 실패, 1 = 성공
+ if (deletedCount == 0) {
+ throw new CommentNotFoundException();
+ }
+ log.info("[COMMENT][HARD_DELETE] commentId={}, deletedCount={}", commentId, deletedCount);
+ }
+
+ // 댓글 전체 조회
+ public CursorPageResponseCommentDto findAll(
+ Long userId, CommentSearchRequest request
+ ) {
+ log.info("[COMMENT][FIND_ALL][START] userId={}, articleId={}, orderBy={}, cursor={}, after={}, limit={}",
+ userId,
+ request.getArticleId(),
+ request.getOrderBy(),
+ request.getCursor(),
+ request.getAfter(),
+ request.getLimit()
+ );
+
+ return commentRepository.searchComments(
+ request.getArticleId(),
+ request.getOrderBy(),
+ request.getCursor(),
+ request.getAfter(),
+ request.getLimit(),
+ userId
+ );
+ }
+
+ // === 내부 유틸 ===
+
+ // 작성자 확인
+ private void validateOwnership(Comment comment, Long userId) {
+ if (!comment.isOwnedBy(userId))
+ throw new CommentForbiddenException();
+ }
+
+ // commentId 확인
+ private Comment getCommentById(Long commentId) {
+ return commentRepository.findById(commentId).orElseThrow(CommentNotFoundException::new);
+ }
+
+ // userId 확인
+ private User getUserById(Long userId) {
+ return userRepository.findById(userId).orElseThrow(CommentUserNotFoundException::new);
+ }
+
+ // articleId 확인
+ private Article getArticleById(Long articleId) {
+ return articleRepository.findById(articleId).orElseThrow(CommentArticleNotFoundException::new);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/config/AWSConfig.java b/monew-api/src/main/java/com/monew/monew_api/common/config/AWSConfig.java
new file mode 100644
index 0000000..7dbdc05
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/config/AWSConfig.java
@@ -0,0 +1,36 @@
+package com.monew.monew_api.common.config;
+
+import lombok.Getter;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+
+@Getter
+@Configuration
+public class AWSConfig {
+ @Value("${aws.accessKeyId}")
+ private String accessKey;
+
+ @Value("${aws.secretKey}")
+ private String secretKey;
+
+ @Value("${aws.region}")
+ private String region;
+
+ @Value("${aws.bucket}")
+ private String bucket;
+
+ @Bean
+ public S3Client s3Client() {
+ AwsBasicCredentials awsCredentials = AwsBasicCredentials.create(accessKey, secretKey);
+
+ return S3Client.builder()
+ .region(Region.of(region))
+ .credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/config/QuerydslConfig.java b/monew-api/src/main/java/com/monew/monew_api/common/config/QuerydslConfig.java
new file mode 100644
index 0000000..ca009b2
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/config/QuerydslConfig.java
@@ -0,0 +1,19 @@
+package com.monew.monew_api.common.config;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class QuerydslConfig {
+
+ @PersistenceContext
+ private EntityManager em;
+
+ @Bean
+ public JPAQueryFactory jpaQueryFactory() {
+ return new JPAQueryFactory(em);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/config/SecurityConfig.java b/monew-api/src/main/java/com/monew/monew_api/common/config/SecurityConfig.java
new file mode 100644
index 0000000..01c4a3f
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/config/SecurityConfig.java
@@ -0,0 +1,41 @@
+package com.monew.monew_api.common.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+@Configuration
+public class SecurityConfig {
+
+ /**
+ * 개발 환경용 PasswordEncoder - 평문 비밀번호 사용
+ * 테스트 및 개발 편의성을 위해 비밀번호를 암호화하지 않음
+ */
+ @Bean
+ @Profile("dev")
+ public PasswordEncoder devPasswordEncoder() {
+ return new PasswordEncoder() {
+ @Override
+ public String encode(CharSequence rawPassword) {
+ return rawPassword.toString();
+ }
+
+ @Override
+ public boolean matches(CharSequence rawPassword, String encodedPassword) {
+ return rawPassword.toString().equals(encodedPassword);
+ }
+ };
+ }
+
+ /**
+ * 프로덕션 환경용 PasswordEncoder - BCrypt 암호화 사용
+ * 실제 배포 환경에서 안전한 비밀번호 저장
+ */
+ @Bean
+ @Profile("prod")
+ public PasswordEncoder prodPasswordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/config/SwaggerConfig.java b/monew-api/src/main/java/com/monew/monew_api/common/config/SwaggerConfig.java
new file mode 100644
index 0000000..4ec4ac6
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/config/SwaggerConfig.java
@@ -0,0 +1,28 @@
+package com.monew.monew_api.common.config;
+
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.info.License;
+import io.swagger.v3.oas.models.servers.Server;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.List;
+
+@Configuration
+public class SwaggerConfig {
+
+ @Bean
+ public OpenAPI openAPI() {
+ return new OpenAPI()
+ .info(new Info()
+ .title("Monew API 문서")
+ .description("Monew 프로젝트의 REST API 명세서입니다.")
+ .version("v1.0.0")
+ .license(new License().name("MIT License")))
+ .servers(List.of(
+ new Server().url("http://localhost:8080").description("개발 서버"),
+ new Server().url("https://api.monew.com").description("운영 서버")
+ ));
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/config/WebConfig.java b/monew-api/src/main/java/com/monew/monew_api/common/config/WebConfig.java
new file mode 100644
index 0000000..397c98b
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/config/WebConfig.java
@@ -0,0 +1,26 @@
+package com.monew.monew_api.common.config;
+
+import com.monew.monew_api.common.interceptor.MDCLoggingInterceptor;
+import com.monew.monew_api.common.interceptor.UserIdHeaderInterceptor;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+@RequiredArgsConstructor
+public class WebConfig implements WebMvcConfigurer {
+
+ private final MDCLoggingInterceptor mdcLoggingInterceptor;
+ private final UserIdHeaderInterceptor userIdHeaderInterceptor;
+
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+ registry.addInterceptor(mdcLoggingInterceptor)
+ .addPathPatterns("/**");
+
+ registry.addInterceptor(userIdHeaderInterceptor)
+ .addPathPatterns("/**")
+ .excludePathPatterns("/api/users/login", "/api/users");
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/dto/CursorPageResponse.java b/monew-api/src/main/java/com/monew/monew_api/common/dto/CursorPageResponse.java
new file mode 100644
index 0000000..9424881
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/dto/CursorPageResponse.java
@@ -0,0 +1,14 @@
+package com.monew.monew_api.common.dto;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+public record CursorPageResponse(
+ List content,
+ String nextCursor,
+ LocalDateTime nextAfter,
+ int size,
+ long totalElements,
+ boolean hasNext
+) {
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseCreatedEntity.java b/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseCreatedEntity.java
new file mode 100644
index 0000000..9d5c107
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseCreatedEntity.java
@@ -0,0 +1,20 @@
+package com.monew.monew_api.common.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.MappedSuperclass;
+import lombok.Getter;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import java.time.LocalDateTime;
+
+@Getter
+@MappedSuperclass
+@EntityListeners(AuditingEntityListener.class)
+public abstract class BaseCreatedEntity extends BaseIdEntity {
+
+ @CreatedDate
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseIdEntity.java b/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseIdEntity.java
new file mode 100644
index 0000000..a3e8494
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseIdEntity.java
@@ -0,0 +1,16 @@
+package com.monew.monew_api.common.entity;
+
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.MappedSuperclass;
+import lombok.Getter;
+
+@Getter
+@MappedSuperclass
+public abstract class BaseIdEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseTimeEntity.java b/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseTimeEntity.java
new file mode 100644
index 0000000..d1d7cbb
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseTimeEntity.java
@@ -0,0 +1,25 @@
+package com.monew.monew_api.common.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.MappedSuperclass;
+import lombok.Getter;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import java.time.LocalDateTime;
+
+@Getter
+@MappedSuperclass
+@EntityListeners(AuditingEntityListener.class)
+public abstract class BaseTimeEntity extends BaseIdEntity {
+
+ @CreatedDate
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ @LastModifiedDate
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt;
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/BaseException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/BaseException.java
new file mode 100644
index 0000000..6c9c1bc
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/BaseException.java
@@ -0,0 +1,26 @@
+package com.monew.monew_api.common.exception;
+
+import lombok.Getter;
+
+import java.time.Instant;
+import java.util.Map;
+
+@Getter
+public class BaseException extends RuntimeException {
+
+ private final Instant timestamp;
+ private final ErrorCode errorCode;
+ private final Map details;
+
+ public BaseException(ErrorCode errorCode) {
+ this.timestamp = Instant.now();
+ this.errorCode = errorCode;
+ this.details = Map.of();
+ }
+
+ public BaseException(ErrorCode errorCode, Map details) {
+ this.timestamp = Instant.now();
+ this.errorCode = errorCode;
+ this.details = details;
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java
new file mode 100644
index 0000000..26f3d82
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java
@@ -0,0 +1,49 @@
+package com.monew.monew_api.common.exception;
+
+import lombok.Getter;
+import org.springframework.http.HttpStatus;
+
+@Getter
+public enum ErrorCode {
+
+ // 사용자 - USER
+ USER_EMAIL_DUPLICATED(HttpStatus.CONFLICT.value(), "이미 존재하는 이메일입니다."),
+ USER_UNAUTHORIZED(HttpStatus.UNAUTHORIZED.value(), "이메일 또는 비밀번호가 일치하지 않습니다."),
+ USER_FORBIDDEN(HttpStatus.FORBIDDEN.value(), "수정 또는 삭제 권한이 없습니다."),
+ USER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "사용자 정보를 찾을 수 없습니다."),
+
+ // 관심사 - INTEREST
+ INTEREST_DUPLICATED(HttpStatus.CONFLICT.value(), "유사한 관심사가 이미 존재합니다."),
+ INTEREST_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "관심사 정보를 찾을 수 없습니다."),
+
+ //관심사 구독 - SUBSCRIBE
+ SUBSCRIBE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "구독 정보를 찾을 수 없습니다."),
+ SUBSCRIBE_DUPLICATE(HttpStatus.CONFLICT.value(), "이미 구독 중입니다."),
+
+ // 뉴스 기사 - ARTICLE
+ ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "뉴스 기사 정보를 찾을 수 없습니다."),
+ ARTICLE_ALREADY_VIEWED(HttpStatus.CONFLICT.value(), "이미 조회한 기사입니다."),
+
+ // 댓글 - COMMENT
+ COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "댓글 정보를 찾을 수 없습니다."),
+ COMMENT_FORBIDDEN(HttpStatus.FORBIDDEN.value(), "댓글 수정 또는 삭제 권한이 없습니다."),
+ COMMENT_ALREADY_LIKED(HttpStatus.CONFLICT.value(), "이미 좋아요한 댓글입니다."),
+ COMMENT_NOT_LIKED(HttpStatus.BAD_REQUEST.value(), "좋아요하지 않은 댓글은 취소할 수 없습니다."),
+ COMMENT_USER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "댓글 작성자를 찾을 수 없습니다."),
+ COMMENT_ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "댓글이 연결된 기사를 찾을 수 없습니다."),
+ COMMENT_INVALID_USER_ID(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 사용자 ID 형식입니다."),
+ COMMENT_INVALID_ARTICLE_ID(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 기사 ID 형식입니다."),
+
+ // 알림 - NOTIFICATION
+ NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "알림 정보를 찾을 수 없습니다."),
+ NOTIFICATION_ACCESS_DENIED(HttpStatus.FORBIDDEN.value(), "해당 알림에 접근할 권한이 없습니다."),
+ NOTIFICATION_ALREADY_CONFIRMED(HttpStatus.CONFLICT.value(), "이미 확인된 알림입니다.");
+
+ private final int status;
+ private final String message;
+
+ ErrorCode(int status, String message) {
+ this.status = status;
+ this.message = message;
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorResponse.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorResponse.java
new file mode 100644
index 0000000..c0bcdce
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorResponse.java
@@ -0,0 +1,35 @@
+package com.monew.monew_api.common.exception;
+
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+
+@Getter
+@Builder
+public class ErrorResponse {
+ private final Instant timestamp;
+ private final String code;
+ private final String message;
+ private final Map details;
+ private final String exceptionType;
+ private final int status;
+
+ public static ErrorResponse of(BaseException e, String path) {
+ ErrorCode errorCode = e.getErrorCode();
+
+ Map mergedDetails = new HashMap<>(e.getDetails());
+ mergedDetails.put("path", path);
+
+ return ErrorResponse.builder()
+ .timestamp(e.getTimestamp())
+ .status(errorCode.getStatus())
+ .code(errorCode.name())
+ .message(errorCode.getMessage())
+ .details(mergedDetails)
+ .exceptionType(e.getClass().getSimpleName())
+ .build();
+ }
+}
\ 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
new file mode 100644
index 0000000..00b7400
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/GlobalExceptionHandler.java
@@ -0,0 +1,77 @@
+package com.monew.monew_api.common.exception;
+
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+
+@Slf4j
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(BaseException.class)
+ public ResponseEntity handleBaseException(BaseException e, HttpServletRequest request) {
+ log.warn("[비즈니스 예외 발생] 코드: {}, 메시지: {}, 요청 URI: {}",
+ e.getErrorCode().name(),
+ e.getMessage(),
+ request.getRequestURI());
+
+ return ResponseEntity
+ .status(e.getErrorCode().getStatus())
+ .body(ErrorResponse.of(e, request.getRequestURI()));
+ }
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity handleValidationExceptions(MethodArgumentNotValidException e, HttpServletRequest request) {
+ Map fieldErrors = new HashMap<>();
+ for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
+ fieldErrors.put(fieldError.getField(), fieldError.getDefaultMessage());
+ }
+
+ log.warn("[입력값 검증 실패] 요청 URI: {}, 에러 필드 수: {}, 상세: {}",
+ request.getRequestURI(),
+ fieldErrors.size(),
+ fieldErrors);
+
+ ErrorResponse response = ErrorResponse.builder()
+ .timestamp(Instant.now())
+ .status(400)
+ .code("VALIDATION_ERROR")
+ .message("요청 데이터의 유효성 검증에 실패했습니다.")
+ .details(Map.of(
+ "path", request.getRequestURI(),
+ "errors", fieldErrors
+ ))
+ .exceptionType(e.getClass().getSimpleName())
+ .build();
+
+ return ResponseEntity.badRequest().body(response);
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity handleUnexpectedException(Exception e, HttpServletRequest request) {
+ log.error("[서버 내부 오류] 예외 타입: {}, 메시지: {}, URI: {}",
+ e.getClass().getSimpleName(),
+ e.getMessage(),
+ request.getRequestURI(),
+ e);
+
+ ErrorResponse response = ErrorResponse.builder()
+ .timestamp(Instant.now())
+ .status(500)
+ .code("INTERNAL_SERVER_ERROR")
+ .message("서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요.")
+ .details(Map.of("path", request.getRequestURI()))
+ .exceptionType(e.getClass().getSimpleName())
+ .build();
+
+ return ResponseEntity.status(500).body(response);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/article/ArticleAlreadyViewedException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/article/ArticleAlreadyViewedException.java
new file mode 100644
index 0000000..62d0cd4
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/article/ArticleAlreadyViewedException.java
@@ -0,0 +1,17 @@
+package com.monew.monew_api.common.exception.article;
+
+import com.monew.monew_api.common.exception.BaseException;
+import com.monew.monew_api.common.exception.ErrorCode;
+
+import java.util.Map;
+
+public class ArticleAlreadyViewedException extends BaseException {
+
+ public ArticleAlreadyViewedException() {
+ super(ErrorCode.ARTICLE_ALREADY_VIEWED);
+ }
+
+ public ArticleAlreadyViewedException(Map details) {
+ super(ErrorCode.ARTICLE_ALREADY_VIEWED, details);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/article/ArticleNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/article/ArticleNotFoundException.java
new file mode 100644
index 0000000..b6b1ee0
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/article/ArticleNotFoundException.java
@@ -0,0 +1,17 @@
+package com.monew.monew_api.common.exception.article;
+
+import com.monew.monew_api.common.exception.BaseException;
+import com.monew.monew_api.common.exception.ErrorCode;
+
+import java.util.Map;
+
+public class ArticleNotFoundException extends BaseException {
+
+ public ArticleNotFoundException() {
+ super(ErrorCode.ARTICLE_NOT_FOUND);
+ }
+
+ public ArticleNotFoundException(Map details) {
+ super(ErrorCode.ARTICLE_NOT_FOUND, details);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentAlreadyLikedException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentAlreadyLikedException.java
new file mode 100644
index 0000000..5574141
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentAlreadyLikedException.java
@@ -0,0 +1,10 @@
+package com.monew.monew_api.common.exception.comment;
+
+import com.monew.monew_api.common.exception.BaseException;
+import com.monew.monew_api.common.exception.ErrorCode;
+
+public class CommentAlreadyLikedException extends BaseException {
+ public CommentAlreadyLikedException() {
+ super(ErrorCode.COMMENT_ALREADY_LIKED);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentArticleNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentArticleNotFoundException.java
new file mode 100644
index 0000000..86ef31c
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentArticleNotFoundException.java
@@ -0,0 +1,10 @@
+package com.monew.monew_api.common.exception.comment;
+
+import com.monew.monew_api.common.exception.BaseException;
+import com.monew.monew_api.common.exception.ErrorCode;
+
+public class CommentArticleNotFoundException extends BaseException {
+ public CommentArticleNotFoundException() {
+ super(ErrorCode.COMMENT_ARTICLE_NOT_FOUND);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentForbiddenException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentForbiddenException.java
new file mode 100644
index 0000000..f08b222
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentForbiddenException.java
@@ -0,0 +1,11 @@
+package com.monew.monew_api.common.exception.comment;
+
+import com.monew.monew_api.common.exception.BaseException;
+import com.monew.monew_api.common.exception.ErrorCode;
+
+public class CommentForbiddenException extends BaseException {
+ public CommentForbiddenException() {
+ super(ErrorCode.COMMENT_FORBIDDEN);
+ }
+
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentInvalidArticleIdException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentInvalidArticleIdException.java
new file mode 100644
index 0000000..eda5feb
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentInvalidArticleIdException.java
@@ -0,0 +1,12 @@
+package com.monew.monew_api.common.exception.comment;
+
+import java.util.Map;
+
+import com.monew.monew_api.common.exception.BaseException;
+import com.monew.monew_api.common.exception.ErrorCode;
+
+public class CommentInvalidArticleIdException extends BaseException {
+ public CommentInvalidArticleIdException(String invalidValue) {
+ super(ErrorCode.COMMENT_INVALID_ARTICLE_ID, Map.of("articleId", invalidValue));
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentInvalidUserIdException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentInvalidUserIdException.java
new file mode 100644
index 0000000..0b5e303
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentInvalidUserIdException.java
@@ -0,0 +1,14 @@
+package com.monew.monew_api.common.exception.comment;
+
+import java.util.Map;
+
+import com.monew.monew_api.common.exception.BaseException;
+import com.monew.monew_api.common.exception.ErrorCode;
+
+public class CommentInvalidUserIdException extends BaseException {
+
+ public CommentInvalidUserIdException(String invalidValue) {
+ super(ErrorCode.COMMENT_INVALID_USER_ID, Map.of("userId", invalidValue));
+ }
+
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotFoundException.java
new file mode 100644
index 0000000..a6527d7
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotFoundException.java
@@ -0,0 +1,13 @@
+package com.monew.monew_api.common.exception.comment;
+
+import com.monew.monew_api.common.exception.BaseException;
+import com.monew.monew_api.common.exception.ErrorCode;
+
+import java.util.Map;
+
+public class CommentNotFoundException extends BaseException {
+
+ public CommentNotFoundException() {
+ super(ErrorCode.COMMENT_NOT_FOUND);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotLikedException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotLikedException.java
new file mode 100644
index 0000000..38202cf
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotLikedException.java
@@ -0,0 +1,10 @@
+package com.monew.monew_api.common.exception.comment;
+
+import com.monew.monew_api.common.exception.BaseException;
+import com.monew.monew_api.common.exception.ErrorCode;
+
+public class CommentNotLikedException extends BaseException {
+ public CommentNotLikedException() {
+ super(ErrorCode.COMMENT_NOT_LIKED);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentUserNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentUserNotFoundException.java
new file mode 100644
index 0000000..ea481c3
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentUserNotFoundException.java
@@ -0,0 +1,10 @@
+package com.monew.monew_api.common.exception.comment;
+
+import com.monew.monew_api.common.exception.BaseException;
+import com.monew.monew_api.common.exception.ErrorCode;
+
+public class CommentUserNotFoundException extends BaseException {
+ public CommentUserNotFoundException() {
+ super(ErrorCode.COMMENT_USER_NOT_FOUND);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/interest/InterestDuplicatedException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/interest/InterestDuplicatedException.java
new file mode 100644
index 0000000..6db377f
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/interest/InterestDuplicatedException.java
@@ -0,0 +1,17 @@
+package com.monew.monew_api.common.exception.interest;
+
+import com.monew.monew_api.common.exception.BaseException;
+import com.monew.monew_api.common.exception.ErrorCode;
+
+import java.util.Map;
+
+public class InterestDuplicatedException extends BaseException {
+
+ public InterestDuplicatedException() {
+ super(ErrorCode.INTEREST_DUPLICATED);
+ }
+
+ public InterestDuplicatedException(Map details) {
+ super(ErrorCode.INTEREST_DUPLICATED, details);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/interest/InterestNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/interest/InterestNotFoundException.java
new file mode 100644
index 0000000..a8e4aeb
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/interest/InterestNotFoundException.java
@@ -0,0 +1,17 @@
+package com.monew.monew_api.common.exception.interest;
+
+import com.monew.monew_api.common.exception.BaseException;
+import com.monew.monew_api.common.exception.ErrorCode;
+
+import java.util.Map;
+
+public class InterestNotFoundException extends BaseException {
+
+ public InterestNotFoundException() {
+ super(ErrorCode.INTEREST_NOT_FOUND);
+ }
+
+ public InterestNotFoundException(Map details) {
+ super(ErrorCode.INTEREST_NOT_FOUND, details);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationAccessDeniedException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationAccessDeniedException.java
new file mode 100644
index 0000000..1ec3406
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationAccessDeniedException.java
@@ -0,0 +1,17 @@
+package com.monew.monew_api.common.exception.notification;
+
+import com.monew.monew_api.common.exception.BaseException;
+import com.monew.monew_api.common.exception.ErrorCode;
+
+import java.util.Map;
+
+public class NotificationAccessDeniedException extends BaseException {
+
+ public NotificationAccessDeniedException() {
+ super(ErrorCode.NOTIFICATION_ACCESS_DENIED);
+ }
+
+ public NotificationAccessDeniedException(Map details) {
+ super(ErrorCode.NOTIFICATION_ACCESS_DENIED, details);
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationAlreadyConfirmedException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationAlreadyConfirmedException.java
new file mode 100644
index 0000000..d361c2a
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationAlreadyConfirmedException.java
@@ -0,0 +1,17 @@
+package com.monew.monew_api.common.exception.notification;
+
+import com.monew.monew_api.common.exception.BaseException;
+import com.monew.monew_api.common.exception.ErrorCode;
+
+import java.util.Map;
+
+public class NotificationAlreadyConfirmedException extends BaseException {
+
+ public NotificationAlreadyConfirmedException() {
+ super(ErrorCode.NOTIFICATION_ALREADY_CONFIRMED);
+ }
+
+ public NotificationAlreadyConfirmedException(Map details) {
+ super(ErrorCode.NOTIFICATION_ALREADY_CONFIRMED, details);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationNotFoundException.java
new file mode 100644
index 0000000..954c995
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationNotFoundException.java
@@ -0,0 +1,17 @@
+package com.monew.monew_api.common.exception.notification;
+
+import com.monew.monew_api.common.exception.BaseException;
+import com.monew.monew_api.common.exception.ErrorCode;
+
+import java.util.Map;
+
+public class NotificationNotFoundException extends BaseException {
+
+ public NotificationNotFoundException() {
+ super(ErrorCode.NOTIFICATION_NOT_FOUND);
+ }
+
+ public NotificationNotFoundException(Map details) {
+ super(ErrorCode.NOTIFICATION_NOT_FOUND, details);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/subscribe/SubscribeDuplicateException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/subscribe/SubscribeDuplicateException.java
new file mode 100644
index 0000000..6d50633
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/subscribe/SubscribeDuplicateException.java
@@ -0,0 +1,16 @@
+package com.monew.monew_api.common.exception.subscribe;
+
+import com.monew.monew_api.common.exception.BaseException;
+import com.monew.monew_api.common.exception.ErrorCode;
+import java.util.Map;
+
+public class SubscribeDuplicateException extends BaseException {
+
+ public SubscribeDuplicateException() {
+ super(ErrorCode.SUBSCRIBE_DUPLICATE);
+ }
+
+ public SubscribeDuplicateException(Map details) {
+ super(ErrorCode.SUBSCRIBE_DUPLICATE, details);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/subscribe/SubscribeNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/subscribe/SubscribeNotFoundException.java
new file mode 100644
index 0000000..47882d8
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/subscribe/SubscribeNotFoundException.java
@@ -0,0 +1,17 @@
+package com.monew.monew_api.common.exception.subscribe;
+
+import com.monew.monew_api.common.exception.BaseException;
+import com.monew.monew_api.common.exception.ErrorCode;
+import java.util.Map;
+
+public class SubscribeNotFoundException extends BaseException {
+
+ public SubscribeNotFoundException() {
+ super(ErrorCode.SUBSCRIBE_NOT_FOUND);
+ }
+
+ public SubscribeNotFoundException(Map details) {
+ super(ErrorCode.SUBSCRIBE_NOT_FOUND, details);
+ }
+
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserEmailDuplicateException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserEmailDuplicateException.java
new file mode 100644
index 0000000..9df967d
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserEmailDuplicateException.java
@@ -0,0 +1,17 @@
+package com.monew.monew_api.common.exception.user;
+
+import com.monew.monew_api.common.exception.BaseException;
+import com.monew.monew_api.common.exception.ErrorCode;
+
+import java.util.Map;
+
+public class UserEmailDuplicateException extends BaseException {
+
+ public UserEmailDuplicateException() {
+ super(ErrorCode.USER_EMAIL_DUPLICATED);
+ }
+
+ public UserEmailDuplicateException(Map details) {
+ super(ErrorCode.USER_EMAIL_DUPLICATED, details);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserForbiddenException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserForbiddenException.java
new file mode 100644
index 0000000..795a4c8
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserForbiddenException.java
@@ -0,0 +1,17 @@
+package com.monew.monew_api.common.exception.user;
+
+import com.monew.monew_api.common.exception.BaseException;
+import com.monew.monew_api.common.exception.ErrorCode;
+
+import java.util.Map;
+
+public class UserForbiddenException extends BaseException {
+
+ public UserForbiddenException() {
+ super(ErrorCode.USER_FORBIDDEN);
+ }
+
+ public UserForbiddenException(Map details) {
+ super(ErrorCode.USER_FORBIDDEN, details);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserNotFoundException.java
new file mode 100644
index 0000000..29d3064
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserNotFoundException.java
@@ -0,0 +1,17 @@
+package com.monew.monew_api.common.exception.user;
+
+import com.monew.monew_api.common.exception.BaseException;
+import com.monew.monew_api.common.exception.ErrorCode;
+
+import java.util.Map;
+
+public class UserNotFoundException extends BaseException {
+
+ public UserNotFoundException() {
+ super(ErrorCode.USER_NOT_FOUND);
+ }
+
+ public UserNotFoundException(Map details) {
+ super(ErrorCode.USER_NOT_FOUND, details);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserUnauthorizedException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserUnauthorizedException.java
new file mode 100644
index 0000000..e18eee8
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserUnauthorizedException.java
@@ -0,0 +1,17 @@
+package com.monew.monew_api.common.exception.user;
+
+import com.monew.monew_api.common.exception.BaseException;
+import com.monew.monew_api.common.exception.ErrorCode;
+
+import java.util.Map;
+
+public class UserUnauthorizedException extends BaseException {
+
+ public UserUnauthorizedException() {
+ super(ErrorCode.USER_UNAUTHORIZED);
+ }
+
+ public UserUnauthorizedException(Map details) {
+ super(ErrorCode.USER_UNAUTHORIZED, details);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/interceptor/MDCLoggingInterceptor.java b/monew-api/src/main/java/com/monew/monew_api/common/interceptor/MDCLoggingInterceptor.java
new file mode 100644
index 0000000..86d5afd
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/interceptor/MDCLoggingInterceptor.java
@@ -0,0 +1,47 @@
+package com.monew.monew_api.common.interceptor;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.slf4j.MDC;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import java.util.UUID;
+
+@Component
+public class MDCLoggingInterceptor implements HandlerInterceptor {
+
+ private static final String REQUEST_ID = "requestId";
+ private static final String CLIENT_IP = "clientIp";
+ private static final String REQUEST_METHOD = "requestMethod";
+ private static final String REQUEST_URI = "requestUri";
+
+ @Override
+ public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+ String requestId = UUID.randomUUID().toString().substring(0, 8);
+ String clientIp = extractClientIp(request);
+
+ MDC.put(REQUEST_ID, requestId);
+ MDC.put(CLIENT_IP, clientIp);
+ MDC.put(REQUEST_METHOD, request.getMethod());
+ MDC.put(REQUEST_URI, request.getRequestURI());
+
+ response.addHeader("X-Request-ID", requestId);
+ response.addHeader("X-Client-IP", clientIp);
+
+ return true;
+ }
+
+ @Override
+ public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
+ MDC.clear();
+ }
+
+ private String extractClientIp(HttpServletRequest request) {
+ String ip = request.getHeader("X-Forwarded-For");
+ if (ip != null && !ip.isBlank()) {
+ return ip.split(",")[0].trim();
+ }
+ return request.getRemoteAddr();
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/common/interceptor/UserIdHeaderInterceptor.java b/monew-api/src/main/java/com/monew/monew_api/common/interceptor/UserIdHeaderInterceptor.java
new file mode 100644
index 0000000..b4e3d05
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/common/interceptor/UserIdHeaderInterceptor.java
@@ -0,0 +1,35 @@
+package com.monew.monew_api.common.interceptor;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.slf4j.MDC;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+@Slf4j
+@Component
+public class UserIdHeaderInterceptor implements HandlerInterceptor {
+
+ private static final String USER_ID_HEADER = "MoNew-Request-User-ID";
+ private static final String MDC_USER_ID_KEY = "userId";
+
+ @Override
+ public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+ String userId = request.getHeader(USER_ID_HEADER);
+
+ if (userId != null && !userId.isBlank()) {
+ MDC.put(MDC_USER_ID_KEY, userId);
+ log.info("[사용자 헤더 감지] {} 헤더 값: {}, URI: {}", USER_ID_HEADER, userId, request.getRequestURI());
+ } else {
+ log.info("[사용자 헤더 없음] {} 헤더가 요청에 포함되지 않음, URI: {}", USER_ID_HEADER, request.getRequestURI());
+ }
+
+ return true;
+ }
+
+ @Override
+ public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
+ MDC.remove(MDC_USER_ID_KEY);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java b/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java
new file mode 100644
index 0000000..d2da3d0
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java
@@ -0,0 +1,81 @@
+package com.monew.monew_api.interest.controller;
+
+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.request.InterestUpdateRequest;
+import com.monew.monew_api.interest.dto.response.CursorPageResponseInterestDto;
+import com.monew.monew_api.interest.dto.response.InterestDto;
+import com.monew.monew_api.interest.service.InterestService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springdoc.core.annotations.ParameterObject;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/interests")
+@RequiredArgsConstructor
+@Slf4j
+public class InterestController {
+
+ private final InterestService interestService;
+
+ @PostMapping
+ public ResponseEntity createInterest(
+ @RequestBody @Valid InterestRegisterRequest request
+ ) {
+ log.info("[API 요청] POST/api/interests/ - 관심사 등록 요청 : {}", request);
+ InterestDto response = interestService.createInterest(request);
+ log.info("[API 응답] POST/api/interests/ - 관심사 등록 응답 : {}", response);
+ return ResponseEntity.status(HttpStatus.CREATED).body(response);
+ }
+
+
+ @GetMapping
+ public ResponseEntity getInterests(
+ @RequestHeader("Monew-Request-User-Id") Long userId,
+ @ParameterObject @ModelAttribute CursorPageRequestInterestDto request
+ ) {
+ log.info("[API 요청] GET/api/interests/ - 관심사 조회 요청 : {}", request);
+ CursorPageResponseInterestDto response = interestService.getInterests(userId, request);
+ log.info("[API 응답] GET/api/interests/ - 관심사 조회 응답 : {}", response);
+ return ResponseEntity.ok(response);
+ }
+
+
+ @DeleteMapping("/{interestId}")
+ public ResponseEntity deleteInterest(
+ @PathVariable Long interestId
+ ) {
+ log.info("[API 요청] DELETE/api/interests/{} - 관심사 삭제 요청", interestId);
+ interestService.deleteInterest(interestId);
+ log.info("[API 응답] DELETE/api/interests/{} - 관심사 삭제 응답", interestId);
+ return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
+ }
+
+
+ @PatchMapping("/{interestId}")
+ public ResponseEntity updateInterestKeywords(
+ @PathVariable Long interestId,
+ @RequestBody @Valid InterestUpdateRequest request
+ ) {
+ log.info("[API 요청] PATCH/api/interests/{} - 관심사 키워드 수정 요청 : {}", interestId, request);
+ InterestDto response = interestService
+ .updateInterestKeywords(request, interestId);
+ log.info("[API 응답] PATCH/api/interests/{} - 관심사 키워드 수정 응답 : {}", interestId, response);
+ return ResponseEntity.status(HttpStatus.OK).body(response);
+ }
+
+
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/InterestOrderBy.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/InterestOrderBy.java
new file mode 100644
index 0000000..85b95af
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/InterestOrderBy.java
@@ -0,0 +1,5 @@
+package com.monew.monew_api.interest.dto;
+
+public enum InterestOrderBy {
+ name, subscriberCount
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/CursorPageRequestInterestDto.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/CursorPageRequestInterestDto.java
new file mode 100644
index 0000000..807aeda
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/CursorPageRequestInterestDto.java
@@ -0,0 +1,19 @@
+package com.monew.monew_api.interest.dto.request;
+
+import com.monew.monew_api.interest.dto.InterestOrderBy;
+import com.querydsl.core.types.Order;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDateTime;
+import org.springdoc.core.annotations.ParameterObject;
+
+@ParameterObject
+public record CursorPageRequestInterestDto(
+
+ String keyword, // 검색어(관심사, 키워드)
+ @NotNull InterestOrderBy orderBy,
+ @NotNull Order direction, // 정렬 방향 (ASC, DESC)
+ String cursor, // 커서 값
+ LocalDateTime after, //
+ @NotNull Integer limit // 커서 페이지 크기
+){
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestRegisterRequest.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestRegisterRequest.java
new file mode 100644
index 0000000..877dc68
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestRegisterRequest.java
@@ -0,0 +1,17 @@
+package com.monew.monew_api.interest.dto.request;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import jakarta.validation.constraints.Size;
+import java.util.List;
+
+public record InterestRegisterRequest(
+
+ @JsonProperty("name")
+ @Size(min = 1, max = 50)
+ String name,
+
+ @Size(min = 1, max = 10)
+ List keywords
+) {
+
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestUpdateRequest.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestUpdateRequest.java
new file mode 100644
index 0000000..acd5898
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestUpdateRequest.java
@@ -0,0 +1,13 @@
+package com.monew.monew_api.interest.dto.request;
+
+import jakarta.validation.constraints.Size;
+import java.util.List;
+import lombok.Builder;
+
+
+public record InterestUpdateRequest(
+ @Size(min = 1, max = 10)
+ List keywords
+) {
+
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/CursorPageResponseInterestDto.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/CursorPageResponseInterestDto.java
new file mode 100644
index 0000000..d5bf8e2
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/CursorPageResponseInterestDto.java
@@ -0,0 +1,16 @@
+package com.monew.monew_api.interest.dto.response;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+public record CursorPageResponseInterestDto(
+ List content, // 실제 데이터 목록
+ String nextCursor, // 다음 페이지 요청 위한 커서
+ LocalDateTime nextAfter, // 커서 기준 시점
+ int size, // 한페이지에 담긴 데이터 개수
+ Long totalElements, // 전체 데이터 개수
+ boolean hasNext // 다음 페이지 존재 여부
+
+) {
+
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java
new file mode 100644
index 0000000..7edeec3
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java
@@ -0,0 +1,13 @@
+package com.monew.monew_api.interest.dto.response;
+
+import java.util.List;
+
+public record InterestDto(
+ Long id,
+ String name,
+ List keywords,
+ Long subscriberCount,
+ boolean subscribedByMe
+) {
+
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java
new file mode 100644
index 0000000..f053d3d
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java
@@ -0,0 +1,57 @@
+package com.monew.monew_api.interest.entity;
+
+import com.monew.monew_api.common.entity.BaseTimeEntity;
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.OrderBy;
+import jakarta.persistence.Table;
+import java.util.HashSet;
+import java.util.Set;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "interests")
+@Getter
+@AllArgsConstructor
+@NoArgsConstructor
+public class Interest extends BaseTimeEntity {
+
+ @Column(length = 100, nullable = false, unique = true)
+ private String name;
+
+ @Column(nullable = false)
+ private int subscriberCount = 0;
+
+ @OneToMany(mappedBy = "interest", cascade = CascadeType.ALL, orphanRemoval = true)
+ @OrderBy("createdAt ASC")
+ private Set keywords = new HashSet<>();
+
+ private Interest(String name, int subscriberCount) {
+ this.name = name;
+ this.subscriberCount = subscriberCount;
+ }
+
+ public static Interest create(String name) {
+ return new Interest(name, 0);
+ }
+
+ public InterestKeyword addKeyword(Keyword keyword) {
+ InterestKeyword interestKeyword = InterestKeyword.create(this, keyword);
+ this.keywords.add(interestKeyword);
+ return interestKeyword;
+ }
+
+ public void addSubscriberCount(){
+ this.subscriberCount++;
+
+ }
+
+ public void cancelSubscriberCount(){
+ if(this.subscriberCount > 0){}
+ this.subscriberCount--;
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/entity/InterestKeyword.java b/monew-api/src/main/java/com/monew/monew_api/interest/entity/InterestKeyword.java
new file mode 100644
index 0000000..466dd95
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/interest/entity/InterestKeyword.java
@@ -0,0 +1,35 @@
+package com.monew.monew_api.interest.entity;
+
+import com.monew.monew_api.common.entity.BaseTimeEntity;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "interest_keywords")
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+public class InterestKeyword extends BaseTimeEntity {
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "interest_id", nullable = false)
+ private Interest interest;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "keyword_id", nullable = false)
+ private Keyword keyword;
+
+ public static InterestKeyword create(Interest interest, Keyword keyword) {
+ return new InterestKeyword(interest, keyword);
+ }
+
+}
+
+
+
diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/entity/Keyword.java b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Keyword.java
new file mode 100644
index 0000000..2305036
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Keyword.java
@@ -0,0 +1,22 @@
+package com.monew.monew_api.interest.entity;
+
+import com.monew.monew_api.common.entity.BaseTimeEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "keywords")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+public class Keyword extends BaseTimeEntity {
+
+ @Column(name = "keyword", length = 50, nullable = false, unique = true)
+ private String keyword;
+
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestDeletedEvent.java b/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestDeletedEvent.java
new file mode 100644
index 0000000..ccc2b06
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestDeletedEvent.java
@@ -0,0 +1,17 @@
+package com.monew.monew_api.interest.event;
+
+import java.time.LocalDateTime;
+
+/**
+ * Interest 삭제 이벤트
+ * @param interestId
+ * @param occurredAt
+ */
+public record InterestDeletedEvent(
+ Long interestId,
+ LocalDateTime occurredAt
+) {
+ public static InterestDeletedEvent of(Long interestId) {
+ return new InterestDeletedEvent(interestId, LocalDateTime.now());
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestUpdatedEvent.java b/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestUpdatedEvent.java
new file mode 100644
index 0000000..02a84e8
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestUpdatedEvent.java
@@ -0,0 +1,21 @@
+package com.monew.monew_api.interest.event;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * Interest 정보 변경 이벤트
+ * Interest 정보를 수정했을 때 발행
+ * @param interestId
+ * @param newKeywords
+ * @param occurredAt
+ */
+public record InterestUpdatedEvent(
+ Long interestId,
+ List newKeywords,
+ LocalDateTime occurredAt
+) {
+ public static InterestUpdatedEvent of(Long interestId, List newKeywords) {
+ return new InterestUpdatedEvent(interestId, newKeywords, LocalDateTime.now());
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/mapper/InterestMapper.java b/monew-api/src/main/java/com/monew/monew_api/interest/mapper/InterestMapper.java
new file mode 100644
index 0000000..0b523ee
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/interest/mapper/InterestMapper.java
@@ -0,0 +1,34 @@
+package com.monew.monew_api.interest.mapper;
+
+import com.monew.monew_api.interest.dto.response.InterestDto;
+import com.monew.monew_api.interest.entity.Interest;
+import com.monew.monew_api.interest.entity.InterestKeyword;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+
+
+@Mapper(componentModel = "spring")
+public interface InterestMapper {
+
+ @Mapping(source = "interest.name", target = "name")
+ @Mapping(source = "keywords", target = "keywords")
+ @Mapping(source = "subscribedByMe", target = "subscribedByMe")
+ InterestDto toDto(Interest interest, List keywords, Boolean subscribedByMe);
+
+ @Mapping(target = "subscriberCount", source = "subscriberCount")
+ InterestDto toInterestDto(Interest interest, List keywords, Boolean subscribedByMe,
+ int subscriberCount);
+
+ // 커스텀 매핑 메서드 (Set -> List)
+ default List map(Set interestKeywords) {
+ if (interestKeywords == null) {
+ return Collections.emptyList();
+ }
+ return interestKeywords.stream()
+ .map(ik -> ik.getKeyword().getKeyword())
+ .toList();
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java
new file mode 100644
index 0000000..ba24328
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java
@@ -0,0 +1,31 @@
+package com.monew.monew_api.interest.repository;
+
+import com.monew.monew_api.interest.entity.Interest;
+import com.monew.monew_api.interest.entity.Keyword;
+import org.springframework.data.jpa.repository.EntityGraph;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface InterestRepository extends JpaRepository, InterestRepositoryCustom {
+
+ @Query("""
+ SELECT DISTINCT i
+ FROM Interest i
+ JOIN FETCH i.keywords ik
+ JOIN FETCH ik.keyword k
+ WHERE k = :keyword
+ """)
+ List findAllByKeyword(@Param("keyword") Keyword keyword);
+
+ /**
+ * 특정 관심사와 해당 관심사에 연결된 키워드들을 함께 조회
+ * author : 정영진
+ * 캐시 데이터 업데이트에 필요
+ */
+ @EntityGraph(attributePaths = {"keywords", "keywords.keyword"})
+ Optional findById(Long id);
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustom.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustom.java
new file mode 100644
index 0000000..4ff5852
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustom.java
@@ -0,0 +1,22 @@
+package com.monew.monew_api.interest.repository;
+
+import com.monew.monew_api.interest.dto.InterestOrderBy;
+import com.monew.monew_api.interest.entity.Interest;
+import com.querydsl.core.types.Order;
+import java.time.LocalDateTime;
+import org.springframework.data.domain.Slice;
+
+public interface InterestRepositoryCustom {
+
+ Slice findAll(
+ String keyword,
+ InterestOrderBy sortBy,
+ Order direction,
+ String cursor,
+ LocalDateTime after,
+ int limit
+ );
+
+ Long countFilteredTotalElements(String keyword);
+}
+
diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java
new file mode 100644
index 0000000..eedc14b
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java
@@ -0,0 +1,140 @@
+package com.monew.monew_api.interest.repository;
+
+import com.monew.monew_api.interest.entity.Interest;
+import com.monew.monew_api.interest.dto.InterestOrderBy;
+import com.querydsl.core.BooleanBuilder;
+import com.querydsl.core.types.Order;
+import com.querydsl.core.types.OrderSpecifier;
+import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.jpa.impl.JPAQuery;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import java.time.LocalDateTime;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Slice;
+import org.springframework.data.domain.SliceImpl;
+import org.springframework.stereotype.Repository;
+
+import static com.monew.monew_api.interest.entity.QInterestKeyword.interestKeyword;
+import static com.monew.monew_api.interest.entity.QKeyword.keyword1;
+import static com.monew.monew_api.interest.entity.QInterest.interest;
+
+@Repository
+@RequiredArgsConstructor
+public class InterestRepositoryCustomImpl implements InterestRepositoryCustom {
+
+ private final JPAQueryFactory queryFactory;
+
+ @Override
+ public Slice findAll(
+ String searchKeyword,
+ InterestOrderBy orderBy,
+ Order direction,
+ String cursor,
+ LocalDateTime after,
+ int limit
+ ) {
+ BooleanBuilder builder = new BooleanBuilder();
+
+ if (searchKeyword != null && !searchKeyword.isBlank()) {
+ builder.and(
+ interest.name.containsIgnoreCase(searchKeyword)
+ .or(keyword1.keyword.containsIgnoreCase(searchKeyword))
+ );
+ }
+
+ if (after != null) {
+ builder.and(interest.createdAt.gt(after));
+ }
+
+ Order ord = (direction == null || direction == Order.DESC) ? Order.DESC : Order.ASC;
+ boolean desc = (ord == Order.DESC);
+
+ Long cursorId = null;
+ String cursorName = null;
+ Integer cursorCnt = null;
+
+ OrderSpecifier>[] orderSpec = new OrderSpecifier>[]{};;
+
+ switch (orderBy) {
+ case name -> {
+ cursorName = (cursor == null || cursor.isBlank()) ? null : cursor;
+
+ orderSpec = new OrderSpecifier>[]{
+ new OrderSpecifier<>(ord, interest.name)
+ };
+
+ if (cursorName != null) {
+ builder.and(desc ? interest.name.lt(cursorName) : interest.name.gt(cursorName));
+ }
+ }
+
+ case subscriberCount -> {
+ cursorId = (cursor == null || cursor.isBlank()) ? null : Long.valueOf(cursor);
+
+ if (cursorId != null) {
+ Interest base = queryFactory.selectFrom(interest)
+ .where(interest.id.eq(cursorId))
+ .fetchOne();
+ if (base != null)
+ cursorCnt = base.getSubscriberCount();
+ }
+
+ orderSpec = new OrderSpecifier>[]{
+ new OrderSpecifier<>(ord, interest.subscriberCount),
+ new OrderSpecifier<>(ord, interest.id)
+ };
+
+ if (cursorId != null && cursorCnt != null) {
+ BooleanExpression cut = desc
+ ? interest.subscriberCount.lt(cursorCnt)
+ .or(interest.subscriberCount.eq(cursorCnt).and(interest.id.lt(cursorId)))
+ : interest.subscriberCount.gt(cursorCnt)
+ .or(interest.subscriberCount.eq(cursorCnt).and(interest.id.gt(cursorId)));
+ builder.and(cut);
+ }
+ }
+ }
+
+ List rowsPlusOne = queryFactory
+ .select(interest)
+ .from(interest)
+ .leftJoin(interest.keywords, interestKeyword)
+ .leftJoin(interestKeyword.keyword, keyword1)
+ .where(builder)
+ .groupBy(interest.id)
+ .orderBy(orderSpec)
+ .limit(limit + 1)
+ .fetch();
+
+ boolean hasNext = rowsPlusOne.size() > limit;
+ List content = hasNext ? rowsPlusOne.subList(0, limit) : rowsPlusOne;
+
+ return new SliceImpl<>(content, PageRequest.of(0, limit), hasNext);
+ }
+
+ @Override
+ public Long countFilteredTotalElements(String keyword) {
+ JPAQuery query = queryFactory
+ .select(interest.countDistinct())
+ .from(interest);
+
+ BooleanBuilder builder = new BooleanBuilder();
+
+ // keyword가 있을 때만 조인
+ if (keyword != null && !keyword.isBlank()) {
+ query
+ .leftJoin(interest.keywords, interestKeyword)
+ .leftJoin(interestKeyword.keyword, keyword1);
+ builder.and(
+ interest.name.containsIgnoreCase(keyword)
+ .or(keyword1.keyword.containsIgnoreCase(keyword))
+ );
+ }
+ Long count = query.where(builder).fetchOne();
+ return (count != null) ? count : 0L;
+ }
+}
+
+
diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/KeywordRepository.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/KeywordRepository.java
new file mode 100644
index 0000000..4138e33
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/KeywordRepository.java
@@ -0,0 +1,24 @@
+package com.monew.monew_api.interest.repository;
+
+
+import com.monew.monew_api.interest.entity.Keyword;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface KeywordRepository extends JpaRepository {
+
+ Optional findByKeyword(String keyword);
+
+ List findAllByKeywordIn(Collection keywords);
+
+ @Query("SELECT k FROM Keyword k "
+ + "WHERE k IN :keywords AND NOT EXISTS ("
+ + "SELECT 1 FROM InterestKeyword ik WHERE ik.keyword = k"
+ + ")"
+ )
+ List findOrphanKeywordsIn(@Param("keywords") Collection keywords);
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java
new file mode 100644
index 0000000..2258be6
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java
@@ -0,0 +1,20 @@
+package com.monew.monew_api.interest.service;
+
+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.request.InterestUpdateRequest;
+import com.monew.monew_api.interest.dto.response.CursorPageResponseInterestDto;
+import com.monew.monew_api.interest.dto.response.InterestDto;
+
+public interface InterestService {
+
+ InterestDto createInterest(InterestRegisterRequest request);
+
+ CursorPageResponseInterestDto getInterests(Long userId,
+ CursorPageRequestInterestDto cursorRequest);
+
+ InterestDto updateInterestKeywords(InterestUpdateRequest request, Long interestId);
+
+ void deleteInterest(Long interestId);
+
+}
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
new file mode 100644
index 0000000..7fd6c91
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java
@@ -0,0 +1,304 @@
+package com.monew.monew_api.interest.service;
+
+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.common.exception.interest.InterestNotFoundException;
+import com.monew.monew_api.user.repository.UserRepository;
+import com.monew.monew_api.interest.event.InterestDeletedEvent;
+import com.monew.monew_api.interest.event.InterestUpdatedEvent;
+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.request.InterestUpdateRequest;
+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.InterestKeyword;
+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.monew.monew_api.subscribe.repository.SubscribeRepository.InterestCountProjection;
+import com.querydsl.core.types.Order;
+import jakarta.validation.constraints.Size;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.text.similarity.LevenshteinDistance;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.data.domain.Slice;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class InterestServiceImpl implements InterestService {
+
+ private final InterestRepository interestRepository;
+ private final KeywordRepository keywordRepository;
+ private final SubscribeRepository subscribeRepository;
+
+ private final ArticleRepository articleRepository;
+ private final InterestArticlesRepository interestArticlesRepository;
+ private final InterestArticleKeywordRepository interestArticleKeywordRepository;
+
+ private final InterestMapper interestMapper;
+ private final ApplicationEventPublisher eventPublisher;
+
+ @Override
+ @Transactional
+ public InterestDto createInterest(InterestRegisterRequest request) {
+
+ String interestName = request.name();
+
+ // 유사도 검사
+ String similarName = findSimilarInterestName(interestName);
+ if (similarName != null) {
+ Map details = new HashMap<>();
+ details.put("name", similarName);
+ log.warn("유사한 관심사 이름: {}", similarName);
+ throw new InterestDuplicatedException(details);
+ }
+
+ Interest interest = Interest.create(interestName);
+
+ // 키워드 저장
+ Set keywordSet = new HashSet<>(request.keywords());
+ for (String keyword : keywordSet) {
+ Keyword getKeyword = keywordRepository.findByKeyword(keyword)
+ .orElseGet(() -> keywordRepository.save(new Keyword(keyword)));
+ interest.addKeyword(getKeyword);
+ }
+
+ Interest savedInterest = interestRepository.save(interest);
+
+ List keywords = savedInterest.getKeywords().stream()
+ .map(ik -> ik.getKeyword().getKeyword())
+ .collect(Collectors.toList());
+
+ return interestMapper.toDto(savedInterest, keywords, false);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public CursorPageResponseInterestDto getInterests(Long userId,
+ CursorPageRequestInterestDto request) {
+
+ final String keyword = (request.keyword() == null || request.keyword().isBlank())
+ ? null : request.keyword();
+ final InterestOrderBy orderBy =
+ (request.orderBy() == null) ? InterestOrderBy.name : request.orderBy();
+ final Order direction = (request.direction() == null) ? Order.ASC : request.direction();
+ final String cursor = request.cursor();
+ final LocalDateTime after = request.after();
+ final int limit = request.limit();
+
+ 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();
+
+ // 관심사 Id 수집
+ Set interestIds = interests.stream().map(Interest::getId).collect(Collectors.toSet());
+ // 내가 구독중인 관심사 ID
+ Set subscribedIds = subscribeRepository.findSubscribedByInterestIds(userId,
+ interestIds);
+ // dto 채우기
+ List interestDtos = new ArrayList<>(interests.size());
+ for (Interest interest : interests) {
+ List keywords = interest.getKeywords().stream()
+ .map(ik -> ik.getKeyword().getKeyword())
+ .toList();
+ int subscriberCount = interest.getSubscriberCount();
+ 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);
+ }
+
+ long totalElements = interestRepository.countFilteredTotalElements(keyword);
+ boolean hasNext = slices.hasNext();
+
+ String nextCursor = null;
+ LocalDateTime nextAfter = null;
+ if (hasNext) {
+ Interest last = interests.get(interests.size() - 1);
+
+ if (request.orderBy() == InterestOrderBy.name) {
+ nextCursor = last.getName();
+ } else if (request.orderBy() == InterestOrderBy.subscriberCount) {
+ nextCursor = String.valueOf(last.getId());
+ }
+ nextAfter = last.getCreatedAt();
+ }
+
+ return new CursorPageResponseInterestDto(interestDtos, nextCursor, nextAfter,
+ slices.getSize(), totalElements, hasNext);
+ }
+
+ @Override
+ @Transactional
+ public InterestDto updateInterestKeywords(
+ InterestUpdateRequest request, Long interestId) {
+
+ Interest interest = interestRepository.findById(interestId)
+ .orElseThrow(InterestNotFoundException::new);
+
+ updateKeywords(interest, request.keywords());
+
+ List keywords = interest.getKeywords().stream()
+ .map(ik -> ik.getKeyword().getKeyword())
+ .collect(Collectors.toList());
+
+ // 키워드 수정 이벤트 발행
+ eventPublisher.publishEvent(InterestUpdatedEvent.of(interest.getId(), keywords));
+
+ log.info("interestId = {}, 관심사 키워드 수정 완료 : {}", interestId, keywords);
+ return interestMapper.toDto(interest, keywords, false);
+ }
+
+ @Override
+ @Transactional
+ public void deleteInterest(Long interestId) {
+ Interest interest = interestRepository.findById(interestId)
+ .orElseThrow(InterestNotFoundException::new);
+
+ List articleIds = interestArticlesRepository.findArticleIdsByInterestId(interestId);
+ log.info("관심사({})와 연결된 기사 수: {}", interest.getName(), articleIds.size());
+
+ if (articleIds.isEmpty()) {
+ interestRepository.delete(interest);
+ return;
+ }
+
+ List usedElsewhere =
+ interestArticlesRepository.findArticleIdsUsedByOtherInterests(articleIds, interestId);
+
+ List toDelete = articleIds.stream()
+ .filter(id -> !usedElsewhere.contains(id))
+ .toList();
+
+ int deletedCount = toDelete.size();
+ int undeletedCount = usedElsewhere.size();
+
+ if (!toDelete.isEmpty()) {
+ articleRepository.markAsDeleted(toDelete);
+ log.info("논리 삭제된 기사 수: {}", deletedCount);
+ }
+
+ log.info("삭제 제외된 기사 수(다른 관심사에서 사용 중): {}", undeletedCount);
+
+ interestRepository.delete(interest);
+ eventPublisher.publishEvent(InterestDeletedEvent.of(interest.getId()));
+
+ log.info("관심사 삭제 완료: {}", interest.getName());
+ }
+
+
+ private String findSimilarInterestName(String newInterestName) {
+ for (Interest existingInterest : interestRepository.findAll()) {
+ double similarity = calculateSimilarity(existingInterest.getName(), newInterestName);
+ if (similarity >= 0.8) {
+ return existingInterest.getName();
+ }
+ }
+ return null;
+ }
+
+
+ private double calculateSimilarity(String name1, String name2) {
+ if (name1 == null || name2 == null) {
+ return 0.0;
+ }
+ LevenshteinDistance levenshtein = LevenshteinDistance.getDefaultInstance();
+ int distance = levenshtein.apply(name1, name2);
+ int maxLength = Math.max(name1.length(), name2.length());
+ return 1.0 - ((double) distance / maxLength);
+ }
+
+
+ private void updateKeywords(
+ Interest interest, @Size(min = 1, max = 10) List requestKeywords) {
+
+ Map savedKeywords = interest.getKeywords().stream()
+ .collect(Collectors.toMap(
+ ik -> ik.getKeyword().getKeyword(),
+ ik -> ik));
+
+ Set requestKeywordSet = new HashSet<>(requestKeywords);
+
+ List existingKeywords = keywordRepository.findAllByKeywordIn(requestKeywordSet);
+ Map existingKeywordMap = existingKeywords.stream()
+ .collect(Collectors.toMap(Keyword::getKeyword, k -> k));
+
+ for (String keyword : requestKeywordSet) {
+ if (!savedKeywords.containsKey(keyword)) {
+ Keyword getKeyword = existingKeywordMap.getOrDefault(keyword, new Keyword(keyword));
+ if (getKeyword.getId() == null) {
+ getKeyword = keywordRepository.save(getKeyword);
+ }
+ interest.addKeyword(getKeyword);
+ } else {
+ savedKeywords.remove(keyword);
+ }
+ }
+ removeOrphanKeywords(interest, savedKeywords);
+ }
+
+
+ private void removeOrphanKeywords(Interest interest, Map toRemove) {
+ if (toRemove.isEmpty()) {
+ return;
+ }
+
+ List removedKeywords = toRemove.values().stream()
+ .map(InterestKeyword::getKeyword)
+ .toList();
+
+ interest.getKeywords().removeAll(toRemove.values());
+
+ List removedKeywordIds = removedKeywords.stream()
+ .map(Keyword::getId)
+ .toList();
+
+ List relatedArticleIds =
+ interestArticleKeywordRepository.findArticleIdsByKeywordIds(removedKeywordIds);
+ log.info("고아 키워드 관련 기사 수: {}", relatedArticleIds.size());
+
+ if (!relatedArticleIds.isEmpty()) {
+ List usedElsewhere = interestArticleKeywordRepository.findArticlesUsedElsewhere(
+ relatedArticleIds, removedKeywordIds, interest.getId());
+
+ List toDelete = relatedArticleIds.stream()
+ .filter(id -> !usedElsewhere.contains(id))
+ .toList();
+
+ if (!toDelete.isEmpty()) {
+ articleRepository.markAsDeleted(toDelete);
+ log.info("논리 삭제된 기사 수: {}", toDelete.size());
+ }
+
+ log.info("삭제 제외된 기사 수(다른 관심사/키워드 사용 중): {}", usedElsewhere.size());
+ }
+
+ List orphanKeywords = keywordRepository.findOrphanKeywordsIn(removedKeywords);
+ keywordRepository.deleteAll(orphanKeywords);
+ log.info("고아 키워드 삭제 완료: {}", orphanKeywords.size());
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java b/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java
new file mode 100644
index 0000000..c36bb24
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java
@@ -0,0 +1,48 @@
+package com.monew.monew_api.notification.controller;
+
+import com.monew.monew_api.common.dto.CursorPageResponse;
+import com.monew.monew_api.notification.dto.request.NotificationCursorPageRequest;
+import com.monew.monew_api.notification.dto.response.NotificationDto;
+import com.monew.monew_api.notification.service.NotificationService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/notifications")
+public class NotificationController {
+ private static final String REQUEST_HEADER_USER_ID = "MoNew-Request-User-ID";
+ private final NotificationService notificationService;
+
+ @GetMapping
+ public ResponseEntity> getNotifications(@RequestHeader(REQUEST_HEADER_USER_ID) Long userId,
+ @ModelAttribute @Valid NotificationCursorPageRequest cursorPageRequest) {
+ log.info("[API 요청] GET /api/notifications - 전체 조회, 사용자 ID: {}", userId);
+ CursorPageResponse notifications = notificationService.getNonConfirmedNotifications(userId, cursorPageRequest);
+ log.info("[API 응답] GET /api/notifications - 전체 조회 성공, 사용자 ID: {}, 알림 개수: {}", userId, notifications.size());
+
+ return ResponseEntity.ok(notifications);
+ }
+
+ @PatchMapping("/{notificationId}")
+ public ResponseEntity confirmOne(@RequestHeader(REQUEST_HEADER_USER_ID) Long userId,
+ @PathVariable("notificationId") Long notificationId) {
+ log.info("[API 요청] PATCH /api/notifications/{} - 알림 단건 확인, 사용자 ID: {}", notificationId, userId);
+ notificationService.setOneConfirmed(userId, notificationId);
+ log.info("[API 응답] PATCH /api/notifications/{} - 알림 단건 확인 성공, 사용자 ID: {}", notificationId, userId);
+ return ResponseEntity.ok().build();
+ }
+
+ @PatchMapping
+ public ResponseEntity confirmAll(@RequestHeader("Monew-Request-User-ID") Long userId) {
+ log.info("[API 요청] PATCH /api/notifications - 알림 전체 확인, 사용자 ID: {}", userId);
+ notificationService.setAllConfirmed(userId);
+ log.info("[API 응답] PATCH /api/notifications - 알림 전체 확인 성공, 사용자 ID: {}", userId);
+
+ return ResponseEntity.ok().build();
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationInternalController.java b/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationInternalController.java
new file mode 100644
index 0000000..8272693
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationInternalController.java
@@ -0,0 +1,26 @@
+package com.monew.monew_api.notification.controller;
+
+import com.monew.monew_api.notification.service.NotificationService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+@RestController
+@RequiredArgsConstructor
+public class NotificationInternalController {
+
+ private final NotificationService notificationService;
+
+ /**
+ * 배치 서버가 관심사 관련 기사 등록 알림 생성을 요청하는 엔드포인트
+ * 추후 MQ로 전환 예정
+ * @param newLinkCountsByInterestId 관심사별 등록된 기사 갯수
+ */
+ @PostMapping("/api/internal/notifications/articles-registered")
+ public void createInterestNotification(@RequestBody Map newLinkCountsByInterestId) {
+ notificationService.createInterestRegisteredNotification(newLinkCountsByInterestId);
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/dto/request/NotificationCursorPageRequest.java b/monew-api/src/main/java/com/monew/monew_api/notification/dto/request/NotificationCursorPageRequest.java
new file mode 100644
index 0000000..64e5d18
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/notification/dto/request/NotificationCursorPageRequest.java
@@ -0,0 +1,21 @@
+package com.monew.monew_api.notification.dto.request;
+
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+
+public record NotificationCursorPageRequest(
+ String cursor,
+
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
+ LocalDateTime after,
+
+ @NotNull(message = "조회할 개수는 필수값입니다.")
+ @Min(value = 1, message = "조회 개수는 1 이상이어야 합니다.")
+ @Max(value = 50, message = "조회 개수는 50을 초과할 수 없습니다.")
+ Integer limit
+) {
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/dto/response/NotificationDto.java b/monew-api/src/main/java/com/monew/monew_api/notification/dto/response/NotificationDto.java
new file mode 100644
index 0000000..8e2fdd9
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/notification/dto/response/NotificationDto.java
@@ -0,0 +1,17 @@
+package com.monew.monew_api.notification.dto.response;
+
+import com.monew.monew_api.notification.enums.ResourceType;
+
+import java.time.LocalDateTime;
+
+public record NotificationDto(
+ Long id,
+ LocalDateTime createdAt,
+ LocalDateTime updatedAt,
+ boolean confirmed,
+ Long userId,
+ String content,
+ ResourceType resourceType,
+ Long resourceId
+) {
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java b/monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java
new file mode 100644
index 0000000..1692621
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java
@@ -0,0 +1,44 @@
+package com.monew.monew_api.notification.entity;
+
+import com.monew.monew_api.common.entity.BaseTimeEntity;
+import com.monew.monew_api.user.User;
+import com.monew.monew_api.notification.enums.ResourceType;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Table(name = "notifications")
+@Entity
+public class Notification extends BaseTimeEntity {
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id")
+ private User user;
+
+ @Column(nullable = false, length = 100)
+ private String content;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private ResourceType resourceType;
+
+ @Column(nullable = false)
+ private Long resourceId;
+
+ @Column(nullable = false)
+ private boolean confirmed;
+
+ public Notification(User user, String content, ResourceType resourceType, Long resourceId) {
+ this.user = user;
+ this.content = content;
+ this.resourceType = resourceType;
+ this.resourceId = resourceId;
+ }
+
+ public void confirm() {
+ this.confirmed = true;
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/enums/ResourceType.java b/monew-api/src/main/java/com/monew/monew_api/notification/enums/ResourceType.java
new file mode 100644
index 0000000..ee5a875
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/notification/enums/ResourceType.java
@@ -0,0 +1,5 @@
+package com.monew.monew_api.notification.enums;
+
+public enum ResourceType {
+ interest, comment
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/eventlistener/NotificationEventListener.java b/monew-api/src/main/java/com/monew/monew_api/notification/eventlistener/NotificationEventListener.java
new file mode 100644
index 0000000..633b869
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/notification/eventlistener/NotificationEventListener.java
@@ -0,0 +1,27 @@
+package com.monew.monew_api.notification.eventlistener;
+
+import com.monew.monew_api.comments.event.CommentLikedEvent;
+import com.monew.monew_api.notification.service.NotificationService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.event.EventListener;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class NotificationEventListener {
+
+ private final NotificationService notificationService;
+
+ @Async
+ @EventListener(CommentLikedEvent.class)
+ public void handleCommentLiked(CommentLikedEvent event) {
+ try {
+ notificationService.createCommentLikeNotification(event);
+ } catch (Exception e) {
+ log.error("좋아요 알림 생성 실패: {}", event.commentId(), e);
+ }
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepository.java b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepository.java
new file mode 100644
index 0000000..b030959
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepository.java
@@ -0,0 +1,22 @@
+package com.monew.monew_api.notification.repository;
+
+import com.monew.monew_api.notification.entity.Notification;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+
+import java.time.LocalDateTime;
+
+public interface NotificationRepository extends JpaRepository, NotificationRepositoryCustom {
+
+ @Modifying(clearAutomatically = true)
+ @Query("""
+ UPDATE Notification n SET n.confirmed = true, n.updatedAt = CURRENT_TIMESTAMP
+ WHERE n.user.id = :userId AND n.confirmed = false
+ """)
+ int confirmAllByUserId(Long userId);
+
+ @Modifying(clearAutomatically = true)
+ @Query("DELETE FROM Notification n WHERE n.confirmed = true AND n.updatedAt < :oneWeekAgo")
+ int deleteAllOldConfirmed(LocalDateTime oneWeekAgo);
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepositoryCustom.java b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepositoryCustom.java
new file mode 100644
index 0000000..0a0196d
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepositoryCustom.java
@@ -0,0 +1,9 @@
+package com.monew.monew_api.notification.repository;
+
+import com.monew.monew_api.common.dto.CursorPageResponse;
+import com.monew.monew_api.notification.dto.request.NotificationCursorPageRequest;
+import com.monew.monew_api.notification.dto.response.NotificationDto;
+
+public interface NotificationRepositoryCustom {
+ CursorPageResponse findAllNonConfirmedNotifications(Long id, NotificationCursorPageRequest cursorPageRequest);
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepositoryCustomImpl.java b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepositoryCustomImpl.java
new file mode 100644
index 0000000..ada59bb
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepositoryCustomImpl.java
@@ -0,0 +1,78 @@
+package com.monew.monew_api.notification.repository;
+
+import com.monew.monew_api.common.dto.CursorPageResponse;
+import com.monew.monew_api.notification.dto.request.NotificationCursorPageRequest;
+import com.monew.monew_api.notification.dto.response.NotificationDto;
+import com.querydsl.core.types.Projections;
+import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static com.monew.monew_api.notification.entity.QNotification.notification;
+
+@Repository
+@RequiredArgsConstructor
+public class NotificationRepositoryCustomImpl implements NotificationRepositoryCustom {
+
+ private final JPAQueryFactory queryFactory;
+
+ @Override
+ public CursorPageResponse findAllNonConfirmedNotifications(Long userId, NotificationCursorPageRequest cursorPageRequest) {
+ List results = queryFactory
+ .select(Projections.constructor(NotificationDto.class,
+ notification.id,
+ notification.createdAt,
+ notification.updatedAt,
+ notification.confirmed,
+ notification.user.id,
+ notification.content,
+ notification.resourceType,
+ notification.resourceId))
+ .from(notification)
+ .where(
+ notification.user.id.eq(userId),
+ notification.confirmed.isFalse(),
+ cursorPredicate(cursorPageRequest.cursor(), cursorPageRequest.after())
+ )
+ .orderBy(notification.createdAt.desc(), notification.id.asc())
+ .limit(cursorPageRequest.limit() + 1)
+ .fetch();
+
+ Long totalCountTemp = queryFactory
+ .select(notification.count())
+ .from(notification)
+ .where(notification.user.id.eq(userId).and(notification.confirmed.isFalse()))
+ .fetchOne();
+
+ long totalElements = totalCountTemp != null ? totalCountTemp : 0;
+
+ if (results.size() <= cursorPageRequest.limit()) {
+ return new CursorPageResponse<>(results, null, null, results.size(), totalElements, false);
+ }
+
+ results.remove(results.size() - 1);
+ NotificationDto last = results.get(results.size() - 1);
+
+ return new CursorPageResponse<>(
+ results,
+ String.valueOf(last.id()),
+ last.createdAt(),
+ results.size(),
+ totalElements,
+ true
+ );
+ }
+
+ private BooleanExpression cursorPredicate(String cursor, LocalDateTime after) {
+ if (cursor == null || cursor.isBlank() || after == null) {
+ return null;
+ }
+
+ return (notification.createdAt.eq(after).and(notification.id.gt(Long.parseLong(cursor))))
+ .or(notification.createdAt.lt(after));
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java b/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java
new file mode 100644
index 0000000..2f6e2e3
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java
@@ -0,0 +1,135 @@
+package com.monew.monew_api.notification.service;
+
+import com.monew.monew_api.comments.event.CommentLikedEvent;
+import com.monew.monew_api.common.dto.CursorPageResponse;
+import com.monew.monew_api.common.exception.notification.NotificationAccessDeniedException;
+import com.monew.monew_api.common.exception.notification.NotificationAlreadyConfirmedException;
+import com.monew.monew_api.common.exception.notification.NotificationNotFoundException;
+import com.monew.monew_api.interest.entity.Interest;
+import com.monew.monew_api.notification.dto.request.NotificationCursorPageRequest;
+import com.monew.monew_api.notification.dto.response.NotificationDto;
+import com.monew.monew_api.notification.entity.Notification;
+import com.monew.monew_api.notification.enums.ResourceType;
+import com.monew.monew_api.notification.repository.NotificationRepository;
+import com.monew.monew_api.subscribe.entity.Subscribe;
+import com.monew.monew_api.subscribe.repository.SubscribeRepository;
+import com.monew.monew_api.user.User;
+import com.monew.monew_api.user.repository.UserRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Slf4j
+@RequiredArgsConstructor
+@Service
+@Transactional(readOnly = true)
+public class NotificationService {
+ private final NotificationRepository notificationRepository;
+ private final UserRepository userRepository;
+ private final SubscribeRepository subscribeRepository;
+
+ @Transactional
+ public void createCommentLikeNotification(CommentLikedEvent event) {
+ User commentAuthorIdOnly = userRepository.getReferenceById(event.commentAuthorId());
+
+ String content = String.format("%s님이 나의 댓글을 좋아합니다.", event.likerNickname());
+
+ notificationRepository.save(
+ new Notification(
+ commentAuthorIdOnly,
+ content,
+ ResourceType.comment,
+ event.commentId()));
+ }
+
+ @Transactional
+ public void createInterestRegisteredNotification(Map countsByInterestId) {
+// Map countsByInterestId = event.newLinkCountsByInterestId(); // 기존 event 기반 처리시 사용
+ Set interestIds = countsByInterestId.keySet();
+
+ // 관심사를 구독하고 있는 구독자 조회
+ List subscriptions = subscribeRepository.findAllByInterestIds(interestIds);
+ if (subscriptions.isEmpty()) {
+ return;
+ }
+
+ // 관심사별 구독자 리스트 그룹핑
+ Map> usersByInterestId = subscriptions.stream()
+ .collect(Collectors.groupingBy(
+ subscribe -> subscribe.getInterest().getId(),
+ Collectors.mapping(Subscribe::getUser, Collectors.toList())
+ ));
+
+ // 관심사 ID - 관심사 명
+ Map interestNameMap = subscriptions.stream()
+ .map(Subscribe::getInterest)
+ .distinct()
+ .collect(Collectors.toMap(Interest::getId, Interest::getName));
+
+ // 알림 생성
+ List newNotifications = new ArrayList<>();
+ for (Long interestId : interestIds) {
+ List usersToNotify = usersByInterestId.get(interestId);
+ int count = countsByInterestId.get(interestId);
+ String interestName = interestNameMap.get(interestId);
+
+ if (usersToNotify == null || usersToNotify.isEmpty() || count == 0) {
+ continue;
+ }
+
+ String content = String.format("%s와 관련된 기사가 %d건 등록되었습니다.", interestName, count);
+ for (User user : usersToNotify) {
+ newNotifications.add(new Notification(
+ user,
+ content,
+ ResourceType.interest,
+ interestId
+ ));
+ }
+ }
+
+ if (!newNotifications.isEmpty()) {
+ notificationRepository.saveAll(newNotifications);
+ log.info("[관심사 알림 생성 성공] {}개의 알림 생성", newNotifications.size());
+ }
+ }
+
+ public CursorPageResponse getNonConfirmedNotifications(Long userId, NotificationCursorPageRequest cursorPageRequest) {
+ return notificationRepository.findAllNonConfirmedNotifications(userId, cursorPageRequest);
+ }
+
+ @Transactional
+ public void setOneConfirmed(Long userId, Long notificationId) {
+ Notification notification = notificationRepository.findById(notificationId)
+ .orElseThrow(() -> {
+ log.warn("[알림 조회 실패] 존재하지 않는 알림: {}", notificationId);
+ return new NotificationNotFoundException();
+ });
+
+ if (!notification.getUser().getId().equals(userId)) {
+ log.warn("[알림 확인 실패] 권한 없는 사용자: {}, 알림: {}", userId, notificationId);
+ throw new NotificationAccessDeniedException();
+ }
+
+ if (notification.isConfirmed()) {
+ log.warn("[알림 중복 확인] 이미 확인된 알림: {}", notificationId);
+ throw new NotificationAlreadyConfirmedException();
+ }
+
+ notification.confirm();
+ }
+
+ @Transactional
+ public void setAllConfirmed(Long userId) {
+ int affectedRows = notificationRepository.confirmAllByUserId(userId);
+
+ log.info("[알림 전체 확인] 사용자 ID: {}, 확인된 알림 개수: {}개", userId, affectedRows);
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/controller/SubscribeController.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/controller/SubscribeController.java
new file mode 100644
index 0000000..c1c4170
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/controller/SubscribeController.java
@@ -0,0 +1,44 @@
+package com.monew.monew_api.subscribe.controller;
+
+import com.monew.monew_api.subscribe.dto.SubscribeDto;
+import com.monew.monew_api.subscribe.service.SubscribeService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/interests")
+@RequiredArgsConstructor
+@Slf4j
+public class SubscribeController {
+
+ private final SubscribeService subscribeService;
+
+ @PostMapping("/{interestId}/subscriptions")
+ public ResponseEntity createSubscribe(
+ @PathVariable Long interestId,
+ @RequestHeader("Monew-Request-User-ID") Long userId){
+ log.info("[API 요청] POST/api/interests/{}/subscriptions - 관심사 구독 요청", interestId);
+ SubscribeDto subscribeDto = subscribeService.createSubscribe(interestId, userId);
+ log.info("[API 요청] POST/api/interests/{}/subscriptions - 관심사 구독 응답", interestId);
+ return ResponseEntity.status(HttpStatus.CREATED).body(subscribeDto);
+ }
+
+ @DeleteMapping("/{interestId}/subscriptions")
+ public ResponseEntity deleteSubscribe(
+ @PathVariable Long interestId,
+ @RequestHeader("Monew-Request-User-ID") Long userId){
+ log.info("[API 요청] DELETE/api/interests/{}/subscriptions - 구독 취소 요청", interestId);
+ subscribeService.deleteSubscribe(interestId, userId);
+ log.info("[API 요청] DELETE/api/interests/{}/subscriptions - 구독 취소 응답", interestId);
+ return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
+ }
+
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/dto/SubscribeDto.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/dto/SubscribeDto.java
new file mode 100644
index 0000000..5f113c1
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/dto/SubscribeDto.java
@@ -0,0 +1,17 @@
+package com.monew.monew_api.subscribe.dto;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import lombok.Builder;
+
+@Builder
+public record SubscribeDto(
+ Long id,
+ Long interestId,
+ String interestName,
+ List interestKeywords,
+ int interestSubscriberCount,
+ LocalDateTime createdAt
+) {
+
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/entity/Subscribe.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/entity/Subscribe.java
new file mode 100644
index 0000000..8cc5a4f
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/entity/Subscribe.java
@@ -0,0 +1,34 @@
+package com.monew.monew_api.subscribe.entity;
+
+import com.monew.monew_api.common.entity.BaseCreatedEntity;
+import com.monew.monew_api.user.User;
+import com.monew.monew_api.interest.entity.Interest;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "subscribes")
+@Getter
+@AllArgsConstructor
+@NoArgsConstructor
+public class Subscribe extends BaseCreatedEntity {
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "interest_id", nullable = false)
+ private Interest interest;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User user;
+
+ public static Subscribe create(Interest interest, User user) {
+ return new Subscribe(interest, user);
+ }
+}
+
diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionAddedEvent.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionAddedEvent.java
new file mode 100644
index 0000000..b616832
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionAddedEvent.java
@@ -0,0 +1,36 @@
+package com.monew.monew_api.subscribe.event;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 구독 추가 이벤트
+ * @param userId
+ * @param subscriptionId
+ * @param interestId
+ * @param interestName
+ * @param interestKeywords
+ * @param interestSubscriberCount
+ * @param createdAt
+ * @param occurredAt
+ */
+public record SubscriptionAddedEvent(
+ Long userId,
+ Long subscriptionId,
+ Long interestId,
+ String interestName,
+ List interestKeywords,
+ Integer interestSubscriberCount,
+ LocalDateTime createdAt,
+ LocalDateTime occurredAt
+) {
+ public static SubscriptionAddedEvent of(
+ Long userId, Long subscriptionId, Long interestId, String interestName,
+ List interestKeywords, Integer interestSubscriberCount, LocalDateTime createdAt
+ ) {
+ return new SubscriptionAddedEvent(
+ userId, subscriptionId, interestId, interestName, interestKeywords,
+ interestSubscriberCount, createdAt, LocalDateTime.now()
+ );
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionRemovedEvent.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionRemovedEvent.java
new file mode 100644
index 0000000..9978c85
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionRemovedEvent.java
@@ -0,0 +1,20 @@
+package com.monew.monew_api.subscribe.event;
+
+import java.time.LocalDateTime;
+
+/**
+ * 구독 제거 이벤트
+ * @param userId
+ * @param subscriptionId
+ * @param occurredAt
+ */
+public record SubscriptionRemovedEvent(
+ Long userId,
+ Long subscriptionId,
+ Long interestId,
+ LocalDateTime occurredAt
+) {
+ public static SubscriptionRemovedEvent of(Long userId, Long subscriptionId, Long interestId) {
+ return new SubscriptionRemovedEvent(userId, subscriptionId, interestId, LocalDateTime.now());
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/mapper/SubscribeMapper.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/mapper/SubscribeMapper.java
new file mode 100644
index 0000000..0ce3774
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/mapper/SubscribeMapper.java
@@ -0,0 +1,31 @@
+package com.monew.monew_api.subscribe.mapper;
+
+import com.monew.monew_api.interest.entity.InterestKeyword;
+import com.monew.monew_api.subscribe.dto.SubscribeDto;
+import com.monew.monew_api.subscribe.entity.Subscribe;
+
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.Mappings;
+
+@Mapper(componentModel = "spring")
+public interface SubscribeMapper {
+
+ @Mappings({
+ @Mapping(source = "interest.id", target = "interestId"),
+ @Mapping(source = "interest.name", target = "interestName"),
+ @Mapping(source = "interest.keywords", target = "interestKeywords"),
+ @Mapping(source = "interest.subscriberCount", target = "interestSubscriberCount")
+ })
+ SubscribeDto toSubscribeDto(Subscribe subscribe);
+
+ default List map(Set keywords) {
+ if (keywords == null) return null;
+ return keywords.stream()
+ .map(ik -> ik.getKeyword().getKeyword())
+ .collect(Collectors.toList());
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/repository/SubscribeRepository.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/repository/SubscribeRepository.java
new file mode 100644
index 0000000..2d135d8
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/repository/SubscribeRepository.java
@@ -0,0 +1,46 @@
+package com.monew.monew_api.subscribe.repository;
+
+import com.monew.monew_api.user.User;
+import com.monew.monew_api.interest.entity.Interest;
+import com.monew.monew_api.subscribe.entity.Subscribe;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+@Repository
+public interface SubscribeRepository extends JpaRepository {
+
+ boolean existsByInterestAndUser(Interest interest, User user);
+
+ @Query("SELECT s.interest.id FROM Subscribe s " +
+ "WHERE s.user.id = :userId AND s.interest.id IN :interestIds")
+ Set findSubscribedByInterestIds(@Param("userId") Long userId,
+ @Param("interestIds") Set interestIds);
+
+ Optional findByInterestAndUser(Interest interest, User user);
+
+ // 관심사별로 구독자 수 벌크 집계
+ @Query("SELECT s.interest.id AS interestId, COUNT(s.id) AS count " +
+ "FROM Subscribe s WHERE s.interest.id IN :interestIds GROUP BY s.interest.id")
+ List countByInterestIds(@Param("interestIds") Set interestIds);
+
+ @Query("""
+ SELECT s FROM Subscribe s
+ JOIN FETCH s.user
+ JOIN FETCH s.interest
+ WHERE s.interest.id IN :interestIds
+ AND s.user.deletedAt IS NULL
+ """)
+ List findAllByInterestIds(Set interestIds);
+
+ interface InterestCountProjection {
+
+ Long getInterestId();
+ Long getCount();
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeService.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeService.java
new file mode 100644
index 0000000..b6b42f0
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeService.java
@@ -0,0 +1,11 @@
+package com.monew.monew_api.subscribe.service;
+
+import com.monew.monew_api.subscribe.dto.SubscribeDto;
+
+public interface SubscribeService {
+
+ SubscribeDto createSubscribe(Long interestId, Long userId);
+
+ void deleteSubscribe(Long interestId, Long userId);
+
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java
new file mode 100644
index 0000000..aac32f1
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java
@@ -0,0 +1,90 @@
+package com.monew.monew_api.subscribe.service;
+
+import com.monew.monew_api.common.exception.interest.InterestNotFoundException;
+import com.monew.monew_api.common.exception.subscribe.SubscribeDuplicateException;
+import com.monew.monew_api.common.exception.subscribe.SubscribeNotFoundException;
+import com.monew.monew_api.common.exception.user.UserNotFoundException;
+import com.monew.monew_api.user.User;
+import com.monew.monew_api.user.repository.UserRepository;
+import com.monew.monew_api.interest.entity.Interest;
+import com.monew.monew_api.interest.repository.InterestRepository;
+import com.monew.monew_api.subscribe.dto.SubscribeDto;
+import com.monew.monew_api.subscribe.entity.Subscribe;
+import com.monew.monew_api.subscribe.event.SubscriptionAddedEvent;
+import com.monew.monew_api.subscribe.event.SubscriptionRemovedEvent;
+import com.monew.monew_api.subscribe.mapper.SubscribeMapper;
+import com.monew.monew_api.subscribe.repository.SubscribeRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SubscribeServiceImpl implements SubscribeService {
+
+ private final InterestRepository interestRepository;
+ private final UserRepository userRepository;
+ private final SubscribeRepository subscribeRepository;
+ private final SubscribeMapper subscribeMapper;
+ private final ApplicationEventPublisher eventPublisher;
+
+ @Override
+ @Transactional
+ public SubscribeDto createSubscribe(Long interestId, Long userId) {
+
+ Interest interest = interestRepository.findById(interestId)
+ .orElseThrow(InterestNotFoundException::new);
+ User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new);
+
+ if(subscribeRepository.existsByInterestAndUser(interest, user)){
+ throw new SubscribeDuplicateException();
+ }
+ log.info("현재 관심사 구독자 수 : {}", interest.getSubscriberCount());
+ interest.addSubscriberCount();
+ log.info("관심사 구독 후 구독자 수: {}", interest.getSubscriberCount());
+
+ Subscribe subscribe = Subscribe.create(interest, user);
+ Subscribe saved = subscribeRepository.save(subscribe);
+
+ /* 이벤트 발행 keyword의 내용 그대로 캐시에 저장되어서 조회했습니다! */
+ List keywordNames = interest.getKeywords().stream()
+ .map(ik -> ik.getKeyword().getKeyword())
+ .toList();
+ eventPublisher.publishEvent(SubscriptionAddedEvent.of(
+ user.getId(),
+ saved.getId(),
+ interest.getId(),
+ interest.getName(),
+ keywordNames,
+ interest.getSubscriberCount(),
+ saved.getCreatedAt()
+ ));
+
+ return subscribeMapper.toSubscribeDto(saved);
+ }
+
+ @Override
+ @Transactional
+ public void deleteSubscribe(Long interestId, Long userId) {
+
+ Interest interest = interestRepository.findById(interestId)
+ .orElseThrow(InterestNotFoundException::new);
+ User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new);
+
+ Subscribe subscribe = subscribeRepository.findByInterestAndUser(interest,user)
+ .orElseThrow(SubscribeNotFoundException::new);
+
+ subscribeRepository.delete(subscribe);
+
+ eventPublisher.publishEvent(SubscriptionRemovedEvent.of(user.getId(), subscribe.getId(), interest.getId()));
+
+ log.info("현재 관심사 구독자 수 : {}", interest.getSubscriberCount());
+ interest.cancelSubscriberCount();
+ log.info("관심사 구독 취소 후 구독자 수: {}", interest.getSubscriberCount());
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/user/User.java b/monew-api/src/main/java/com/monew/monew_api/user/User.java
new file mode 100644
index 0000000..85199a5
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/user/User.java
@@ -0,0 +1,52 @@
+package com.monew.monew_api.user;
+
+import com.monew.monew_api.common.entity.BaseTimeEntity;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "users")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class User extends BaseTimeEntity {
+
+ @Column(nullable = false, unique = true, length = 255)
+ private String email;
+
+ @Column(nullable = false, length = 100)
+ private String nickname;
+
+ @Column(nullable = false, length = 100)
+ private String password;
+
+ @Column(name = "deleted_at")
+ private LocalDateTime deletedAt;
+
+ @Builder
+ public User(String email, String nickname, String password) {
+ this.email = email;
+ this.nickname = nickname;
+ this.password = password;
+ }
+
+ public void updateNickname(String nickname) {
+ this.nickname = nickname;
+ }
+
+ public void updatePassword(String password) {
+ this.password = password;
+ }
+
+ public void softDelete() {
+ this.deletedAt = LocalDateTime.now();
+ }
+
+ public boolean isDeleted() {
+ return this.deletedAt != null;
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/user/controller/UserController.java b/monew-api/src/main/java/com/monew/monew_api/user/controller/UserController.java
new file mode 100644
index 0000000..7c4bd3a
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/user/controller/UserController.java
@@ -0,0 +1,66 @@
+package com.monew.monew_api.user.controller;
+
+import com.monew.monew_api.user.dto.*;
+import com.monew.monew_api.user.dto.UserDto;
+import com.monew.monew_api.user.dto.UserLoginRequest;
+import com.monew.monew_api.user.dto.UserRegisterRequest;
+import com.monew.monew_api.user.dto.UserUpdateRequest;
+import com.monew.monew_api.user.service.UserService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+public class UserController {
+
+ private final UserService userService;
+
+ @PostMapping("/api/users")
+ public ResponseEntity signup(@Valid @RequestBody UserRegisterRequest request) {
+ log.info("[API 요청] POST /api/users - 회원가입 요청, 이메일: {}", request.getEmail());
+ UserDto response = userService.signup(request);
+ log.info("[API 응답] POST /api/users - 회원가입 성공, 사용자 ID: {}", response.getId());
+ return ResponseEntity.status(HttpStatus.CREATED).body(response);
+ }
+
+ @PostMapping("/api/users/login")
+ public ResponseEntity login(@Valid @RequestBody UserLoginRequest request) {
+ log.info("[API 요청] POST /api/users/login - 로그인 요청, 이메일: {}", request.getEmail());
+ UserDto response = userService.login(request);
+ log.info("[API 응답] POST /api/users/login - 로그인 성공, 사용자 ID: {}", response.getId());
+ return ResponseEntity.ok()
+ .header("MoNew-Request-User-ID", response.getId().toString())
+ .body(response);
+ }
+
+ @PatchMapping("/api/users/{userId}")
+ public ResponseEntity updateUser(
+ @PathVariable Long userId,
+ @Valid @RequestBody UserUpdateRequest request) {
+ log.info("[API 요청] PATCH /api/users/{} - 사용자 정보 수정 요청", userId);
+ UserDto response = userService.updateUser(userId, request);
+ log.info("[API 응답] PATCH /api/users/{} - 사용자 정보 수정 성공", userId);
+ return ResponseEntity.ok(response);
+ }
+
+ @DeleteMapping("/api/users/{userId}")
+ public ResponseEntity softDeleteUser(@PathVariable Long userId) {
+ log.info("[API 요청] DELETE /api/users/{} - 사용자 삭제 요청", userId);
+ userService.softDeleteUser(userId);
+ log.info("[API 응답] DELETE /api/users/{} - 사용자 삭제 성공", userId);
+ return ResponseEntity.noContent().build();
+ }
+
+ @DeleteMapping("/api/users/{userId}/hard")
+ public ResponseEntity hardDeleteUser(@PathVariable Long userId) {
+ log.info("[API 요청] DELETE /api/users/{}/hard - 사용자 영구 삭제 요청", userId);
+ userService.hardDeleteUser(userId);
+ log.info("[API 응답] DELETE /api/users/{}/hard - 사용자 영구 삭제 성공", userId);
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/user/dto/UserDto.java b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserDto.java
new file mode 100644
index 0000000..d229cce
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserDto.java
@@ -0,0 +1,16 @@
+package com.monew.monew_api.user.dto;
+
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+@Getter
+@Builder
+public class UserDto {
+
+ private Long id;
+ private String email;
+ private String nickname;
+ private LocalDateTime createdAt;
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/user/dto/UserLoginRequest.java b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserLoginRequest.java
new file mode 100644
index 0000000..1dc3cb0
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserLoginRequest.java
@@ -0,0 +1,23 @@
+package com.monew.monew_api.user.dto;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@NoArgsConstructor
+public class UserLoginRequest {
+
+ @NotBlank(message = "이메일은 필수입니다.")
+ @Email(message = "유효한 이메일 형식이 아닙니다.")
+ private String email;
+
+ @NotBlank(message = "비밀번호는 필수입니다.")
+ private String password;
+
+ public UserLoginRequest(String email, String password) {
+ this.email = email;
+ this.password = password;
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/user/dto/UserRegisterRequest.java b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserRegisterRequest.java
new file mode 100644
index 0000000..6db35ef
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserRegisterRequest.java
@@ -0,0 +1,31 @@
+package com.monew.monew_api.user.dto;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@NoArgsConstructor
+public class UserRegisterRequest {
+
+ @NotBlank(message = "이메일은 필수입니다.")
+ @Email(message = "유효한 이메일 형식이 아닙니다.")
+ @Size(max = 255, message = "이메일은 255자를 초과할 수 없습니다.")
+ private String email;
+
+ @NotBlank(message = "닉네임은 필수입니다.")
+ @Size(max = 100, message = "닉네임은 100자를 초과할 수 없습니다.")
+ private String nickname;
+
+ @NotBlank(message = "비밀번호는 필수입니다.")
+ @Size(min = 1, max = 100, message = "비밀번호는 100자 이하여야 합니다.")
+ private String password;
+
+ public UserRegisterRequest(String email, String nickname, String password) {
+ this.email = email;
+ this.nickname = nickname;
+ this.password = password;
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/user/dto/UserUpdateRequest.java b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserUpdateRequest.java
new file mode 100644
index 0000000..6dd9bbb
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserUpdateRequest.java
@@ -0,0 +1,21 @@
+package com.monew.monew_api.user.dto;
+
+import jakarta.validation.constraints.Size;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@NoArgsConstructor
+public class UserUpdateRequest {
+
+ @Size(max = 100, message = "닉네임은 100자를 초과할 수 없습니다.")
+ private String nickname;
+
+ public UserUpdateRequest(String nickname) {
+ this.nickname = nickname;
+ }
+
+ public boolean hasNickname() {
+ return nickname != null && !nickname.isBlank();
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/user/repository/UserRepository.java b/monew-api/src/main/java/com/monew/monew_api/user/repository/UserRepository.java
new file mode 100644
index 0000000..ccacd5a
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/user/repository/UserRepository.java
@@ -0,0 +1,19 @@
+package com.monew.monew_api.user.repository;
+
+import com.monew.monew_api.user.User;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface UserRepository extends JpaRepository {
+
+ boolean existsByEmail(String email);
+
+ Optional findByEmail(String email);
+
+ Optional findByIdAndDeletedAtIsNull(Long id);
+
+ Optional findByEmailAndDeletedAtIsNull(String email);
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/user/service/UserService.java b/monew-api/src/main/java/com/monew/monew_api/user/service/UserService.java
new file mode 100644
index 0000000..d70127e
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/user/service/UserService.java
@@ -0,0 +1,139 @@
+package com.monew.monew_api.user.service;
+
+import com.monew.monew_api.common.exception.user.UserEmailDuplicateException;
+import com.monew.monew_api.common.exception.user.UserNotFoundException;
+import com.monew.monew_api.common.exception.user.UserUnauthorizedException;
+import com.monew.monew_api.user.User;
+import com.monew.monew_api.user.dto.*;
+import com.monew.monew_api.user.dto.UserDto;
+import com.monew.monew_api.user.dto.UserLoginRequest;
+import com.monew.monew_api.user.dto.UserRegisterRequest;
+import com.monew.monew_api.user.dto.UserUpdateRequest;
+import com.monew.monew_api.user.repository.UserRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class UserService {
+
+ private final UserRepository userRepository;
+ private final PasswordEncoder passwordEncoder;
+
+ @Transactional
+ public UserDto signup(UserRegisterRequest request) {
+ log.info("[회원가입 시도] 이메일: {}, 닉네임: {}", request.getEmail(), request.getNickname());
+
+ // 이메일 중복 체크
+ if (userRepository.existsByEmail(request.getEmail())) {
+ log.warn("[회원가입 실패] 이메일 중복: {}", request.getEmail());
+ throw new UserEmailDuplicateException();
+ }
+
+ // 비밀번호 암호화
+ String encodedPassword = passwordEncoder.encode(request.getPassword());
+
+ // 사용자 생성
+ User user = User.builder()
+ .email(request.getEmail())
+ .nickname(request.getNickname())
+ .password(encodedPassword)
+ .build();
+
+ User savedUser = userRepository.save(user);
+ log.info("[회원가입 성공] 사용자 ID: {}, 이메일: {}", savedUser.getId(), savedUser.getEmail());
+
+ return UserDto.builder()
+ .id(savedUser.getId())
+ .email(savedUser.getEmail())
+ .nickname(savedUser.getNickname())
+ .createdAt(savedUser.getCreatedAt())
+ .build();
+ }
+
+ public UserDto login(UserLoginRequest request) {
+ log.info("[로그인 시도] 이메일: {}", request.getEmail());
+
+ // 이메일로 사용자 찾기 (논리삭제되지 않은 사용자만)
+ User user = userRepository.findByEmailAndDeletedAtIsNull(request.getEmail())
+ .orElseThrow(() -> {
+ log.warn("[로그인 실패] 존재하지 않는 사용자: {}", request.getEmail());
+ return new UserUnauthorizedException();
+ });
+
+ // 비밀번호 검증
+ if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
+ log.warn("[로그인 실패] 비밀번호 불일치: {}", request.getEmail());
+ throw new UserUnauthorizedException();
+ }
+
+ log.info("[로그인 성공] 사용자 ID: {}, 이메일: {}", user.getId(), user.getEmail());
+
+ return UserDto.builder()
+ .id(user.getId())
+ .email(user.getEmail())
+ .nickname(user.getNickname())
+ .createdAt(user.getCreatedAt())
+ .build();
+ }
+
+ @Transactional
+ public UserDto updateUser(Long userId, UserUpdateRequest request) {
+ log.info("[사용자 정보 수정 시도] 사용자 ID: {}", userId);
+
+ User user = userRepository.findByIdAndDeletedAtIsNull(userId)
+ .orElseThrow(() -> {
+ log.warn("[사용자 정보 수정 실패] 사용자를 찾을 수 없음: {}", userId);
+ return new UserNotFoundException();
+ });
+
+ // 닉네임 업데이트
+ if (request.hasNickname()) {
+ log.debug("[닉네임 변경] 사용자 ID: {}, 변경 전: {}, 변경 후: {}",
+ userId, user.getNickname(), request.getNickname());
+ user.updateNickname(request.getNickname());
+ }
+
+ log.info("[사용자 정보 수정 성공] 사용자 ID: {}", userId);
+
+ return UserDto.builder()
+ .id(user.getId())
+ .email(user.getEmail())
+ .nickname(user.getNickname())
+ .createdAt(user.getCreatedAt())
+ .build();
+ }
+
+ @Transactional
+ public void softDeleteUser(Long userId) {
+ log.info("[사용자 삭제 시도] 사용자 ID: {}", userId);
+
+ User user = userRepository.findByIdAndDeletedAtIsNull(userId)
+ .orElseThrow(() -> {
+ log.warn("[사용자 삭제 실패] 사용자를 찾을 수 없음: {}", userId);
+ return new UserNotFoundException();
+ });
+
+ user.softDelete();
+ log.info("[사용자 삭제 성공] 사용자 ID: {}, 이메일: {}", userId, user.getEmail());
+ }
+
+ @Transactional
+ public void hardDeleteUser(Long userId) {
+ log.info("[사용자 영구 삭제 시도] 사용자 ID: {}", userId);
+
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> {
+ log.warn("[사용자 영구 삭제 실패] 사용자를 찾을 수 없음: {}", userId);
+ return new UserNotFoundException();
+ });
+
+ userRepository.delete(user);
+ log.warn("[사용자 영구 삭제 완료] 사용자 ID: {}, 이메일: {}", userId, user.getEmail());
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java
new file mode 100644
index 0000000..15529c9
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java
@@ -0,0 +1,44 @@
+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.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+@Slf4j
+@RestController
+@RequestMapping("/api/user-activities")
+@RequiredArgsConstructor
+public class UserActivityController {
+
+ private final UserActivityService userActivityService;
+ private final UserActivityCacheService userActivityCacheService;
+
+ /*
+ userActivityCacheService.
+ mongoDB 사용 시 getUserActivityWithCache 메서드
+
+ userActivityService.
+ 단일 쿼리 사용시 getUserActivitySingleQuery 메서드 (네이티브 쿼리)
+ 여러 쿼리 사용시 getUserActivity 메서드
+ */
+ @GetMapping("/{userId}")
+ public ResponseEntity getUserActivity(
+ @PathVariable String userId,
+ @RequestHeader(value = "MoNew-Request-User-ID", required = false) Long requesterId
+ ) {
+ log.info("[활동내역 API 요청]: userId={}", userId);
+
+// UserActivityDto activity = userActivityService.getUserActivitySingleQuery(userId);
+ UserActivityDto activity = userActivityCacheService.getUserActivityWithCache(userId);
+
+ log.info("[활동내역 API 응답]: userId={}, Subscriptions_size={}, Comments_size={}, CommentLikes_size={}, ArticleViews_size={}",
+ activity.getId(), activity.getSubscriptions().size(), activity.getComments().size(),
+ activity.getCommentLikes().size(), activity.getArticleViews().size());
+
+ return ResponseEntity.ok(activity);
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java
new file mode 100644
index 0000000..9da4f28
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java
@@ -0,0 +1,78 @@
+package com.monew.monew_api.useractivity.document;
+
+import jakarta.persistence.Id;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.springframework.data.mongodb.core.index.Indexed;
+import org.springframework.data.mongodb.core.mapping.Document;
+
+import java.time.LocalDateTime;
+import java.util.HashSet;
+import java.util.Set;
+
+/*
+ 역인덱스 문서
+ - key : id 패턴 "도메인_{id}_행동"
+ - 댓글 작성자: "comment_{id}_author" -> {userIds}
+ - 댓글 좋아요: "comment_{id}_likes" -> {userIds}
+ - 기사 조회: "article_{id}_views" -> {userIds}
+ */
+@Document(collection = "reverse_indexes")
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ReverseIndexDocument {
+
+ @Id
+ private String id;
+
+ @Builder.Default
+ private Set userIds = new HashSet<>();
+
+ private LocalDateTime createdAt;
+
+ @Indexed(name = "index_ttl", expireAfter = "1h")
+ private LocalDateTime updatedAt;
+
+ /**
+ * 댓글 작성자 역인덱스 키 생성
+ *
+ * @param commentId 댓글 ID
+ * @return "comment_{commentId}_author"
+ */
+ public static String makeCommentAuthorKey(Long commentId) {
+ return "comment_" + commentId + "_author";
+ }
+
+ /**
+ * 댓글 좋아요 역인덱스 키 생성
+ *
+ * @param commentId 댓글 ID
+ * @return "comment_{commentId}_likes"
+ */
+ public static String makeCommentLikesKey(Long commentId) {
+ return "comment_" + commentId + "_likes";
+ }
+
+ /**
+ * 기사 조회 역인덱스 키 생성
+ *
+ * @param articleId 기사 ID
+ * @return "article_{articleId}_views"
+ */
+ public static String makeArticleViewsKey(Long articleId) {
+ return "article_" + articleId + "_views";
+ }
+
+ /**
+ * Interest 구독자 역인덱스 키 생성
+ * @param interestId 관심사 ID
+ * @return "interest_{interestId}_subs"
+ */
+ public static String makeInterestSubscribersKey(Long interestId) {
+ return "interest_" + interestId + "_subs";
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java
new file mode 100644
index 0000000..ed35f32
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java
@@ -0,0 +1,39 @@
+package com.monew.monew_api.useractivity.document;
+
+import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto;
+import com.monew.monew_api.useractivity.dto.CommentActivityDto;
+import com.monew.monew_api.useractivity.dto.CommentLikeActivityDto;
+import com.monew.monew_api.useractivity.dto.SubscribesActivityDto;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.mongodb.core.index.Indexed;
+import org.springframework.data.mongodb.core.mapping.Document;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Document(collection = "user_activity_cache")
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class UserActivityCacheDocument {
+
+ @Id
+ private String id;
+
+ private String email;
+ private String nickname;
+ private LocalDateTime createdAt;
+
+ private List subscriptions;
+ private List comments;
+ private List commentLikes;
+ private List articleViews;
+
+ @Indexed(name = "cache_ttl", expireAfter = "1h")
+ private LocalDateTime updatedAt;
+}
\ No newline at end of file
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
new file mode 100644
index 0000000..93ee743
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/ArticleViewActivityDto.java
@@ -0,0 +1,60 @@
+package com.monew.monew_api.useractivity.dto;
+
+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 java.time.LocalDateTime;
+
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ArticleViewActivityDto {
+
+ @JsonProperty("id")
+ @JsonAlias({"view_id"})
+ private String id;
+
+ @JsonProperty("viewedBy")
+ @JsonAlias({"viewed_by"})
+ private String viewedBy;
+
+ @JsonProperty("createdAt")
+ @JsonAlias({"created_at"})
+ private LocalDateTime createdAt;
+
+ @JsonProperty("articleId")
+ @JsonAlias({"article_id"})
+ private String articleId;
+
+ @JsonProperty("source")
+ private String source;
+
+ @JsonProperty("sourceUrl")
+ @JsonAlias({"source_url"})
+ private String sourceUrl;
+
+ @JsonProperty("articleTitle")
+ @JsonAlias({"article_title"})
+ private String articleTitle;
+
+ @JsonProperty("articlePublishedDate")
+ @JsonAlias({"publish_date", "article_published_date"})
+ private LocalDateTime articlePublishedDate;
+
+ @JsonProperty("articleSummary")
+ @JsonAlias({"summary", "article_summary"})
+ private String articleSummary;
+
+ @JsonProperty("articleCommentCount")
+ @JsonAlias({"comment_count", "article_comment_count"})
+ private Integer articleCommentCount;
+
+ @JsonProperty("articleViewCount")
+ @JsonAlias({"view_count", "article_view_count"})
+ private Integer articleViewCount;
+}
\ No newline at end of file
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
new file mode 100644
index 0000000..f713a95
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentActivityDto.java
@@ -0,0 +1,48 @@
+package com.monew.monew_api.useractivity.dto;
+
+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 java.time.LocalDateTime;
+
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CommentActivityDto {
+
+ @JsonProperty("id")
+ @JsonAlias({"comment_id"})
+ private String id;
+
+ @JsonProperty("articleId")
+ @JsonAlias({"article_id"})
+ private String articleId;
+
+ @JsonProperty("articleTitle")
+ @JsonAlias({"article_title"})
+ private String articleTitle;
+
+ @JsonProperty("userId")
+ @JsonAlias({"user_id"})
+ private String userId;
+
+ @JsonProperty("userNickname")
+ @JsonAlias({"user_nickname"})
+ private String userNickname;
+
+ @JsonProperty("content")
+ private String content;
+
+ @JsonProperty("likeCount")
+ @JsonAlias({"like_count"})
+ private Integer likeCount;
+
+ @JsonProperty("createdAt")
+ @JsonAlias({"created_at"})
+ private LocalDateTime createdAt;
+}
\ No newline at end of file
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
new file mode 100644
index 0000000..068cf1c
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java
@@ -0,0 +1,57 @@
+package com.monew.monew_api.useractivity.dto;
+
+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 java.time.LocalDateTime;
+
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CommentLikeActivityDto {
+
+ @JsonProperty("id")
+ @JsonAlias({"like_id"})
+ private String id;
+
+ @JsonProperty("createdAt")
+ @JsonAlias({"created_at"})
+ private LocalDateTime createdAt;
+
+ @JsonProperty("commentId")
+ @JsonAlias({"comment_id"})
+ private String commentId;
+
+ @JsonProperty("articleId")
+ @JsonAlias({"article_id"})
+ private String articleId;
+
+ @JsonProperty("articleTitle")
+ @JsonAlias({"article_title"})
+ private String articleTitle;
+
+ @JsonProperty("commentAuthorId")
+ @JsonAlias({"comment_user_id"})
+ private String commentUserId;
+
+ @JsonProperty("commentUserNickname")
+ @JsonAlias({"comment_user_nickname"})
+ private String commentUserNickname;
+
+ @JsonProperty("commentContent")
+ @JsonAlias({"comment_content"})
+ private String commentContent;
+
+ @JsonProperty("commentLikeCount")
+ @JsonAlias({"comment_like_count"})
+ private Integer commentLikeCount;
+
+ @JsonProperty("commentCreatedAt")
+ @JsonAlias({"comment_created_at"})
+ private LocalDateTime commentCreatedAt;
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/SubscribesActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/SubscribesActivityDto.java
new file mode 100644
index 0000000..02e9955
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/SubscribesActivityDto.java
@@ -0,0 +1,45 @@
+package com.monew.monew_api.useractivity.dto;
+
+import com.fasterxml.jackson.annotation.JsonAlias;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.monew.monew_api.useractivity.json.CommaSeparatedToListDeserializer;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class SubscribesActivityDto {
+
+ @JsonProperty("id")
+ @JsonAlias({"subscription_id"})
+ private String id;
+
+ @JsonProperty("createdAt")
+ @JsonAlias({"created_at", "subscription_created_at"})
+ private LocalDateTime createdAt;
+
+ @JsonProperty("interestId")
+ @JsonAlias({"interest_id"})
+ private String interestId;
+
+ @JsonProperty("interestName")
+ @JsonAlias({"interest_name"})
+ private String interestName;
+
+ @JsonProperty("interestSubscriberCount")
+ @JsonAlias({"interest_subscriber_count", "subscriber_count"})
+ private Integer interestSubscriberCount;
+
+ @JsonProperty("interestKeywords")
+ @JsonAlias({"interest_keywords", "keywords"})
+ @JsonDeserialize(using = CommaSeparatedToListDeserializer.class)
+ private List interestKeywords;
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java
new file mode 100644
index 0000000..d46b2fd
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java
@@ -0,0 +1,24 @@
+package com.monew.monew_api.useractivity.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class UserActivityDto {
+ private String id;
+ private String email;
+ private String nickname;
+ private LocalDateTime createdAt;
+ private List subscriptions;
+ private List comments;
+ private List commentLikes;
+ private List articleViews;
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/event/CacheSaveEvent.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/event/CacheSaveEvent.java
new file mode 100644
index 0000000..8760828
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/event/CacheSaveEvent.java
@@ -0,0 +1,16 @@
+package com.monew.monew_api.useractivity.event;
+
+import com.monew.monew_api.useractivity.dto.UserActivityDto;
+
+/**
+ * 캐시 저장 이벤트
+ * PostgreSQL 조회 후 MongoDB에 비동기 캐시 저장
+ * useractivity 내부, create 전략
+ * @param userId
+ * @param data
+ */
+public record CacheSaveEvent(
+ String userId,
+ UserActivityDto data
+) {
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/json/CommaSeparatedToListDeserializer.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/json/CommaSeparatedToListDeserializer.java
new file mode 100644
index 0000000..1212774
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/json/CommaSeparatedToListDeserializer.java
@@ -0,0 +1,29 @@
+package com.monew.monew_api.useractivity.json;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/*
+ CommaSeparatedToListDeserializer
+ - 콤마(,)로 구분된 문자열을 List으로 변환하는 Jackson Deserializer
+ single 쿼리로 조회한 활동 내역에서, 관심사 키워드(interest_keywords) 필드가
+ 콤마로 구분된 문자열로 반환되기 때문에 이를 List으로 변환하기 위해 사용
+ */
+public class CommaSeparatedToListDeserializer extends JsonDeserializer> {
+ @Override
+ public List deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
+ String text = p.getValueAsString();
+ if (text == null || text.isEmpty()) return Collections.emptyList();
+ return Arrays.stream(text.split(","))
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .collect(Collectors.toList());
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/ArticleViewEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/ArticleViewEventListener.java
new file mode 100644
index 0000000..94dbf59
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/ArticleViewEventListener.java
@@ -0,0 +1,56 @@
+package com.monew.monew_api.useractivity.listener;
+
+import com.monew.monew_api.article.event.ArticleViewedEvent;
+import com.monew.monew_api.useractivity.service.CacheUpdateService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+/**
+ * 기사 조회 이벤트 리스너
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ArticleViewEventListener {
+
+ private final CacheUpdateService cacheUpdateService;
+
+ @Async
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ public void handle(ArticleViewedEvent event) {
+ log.info("[Listener] 기사 조회 이벤트 수신: articleId={}, userId={}",
+ event.articleId(), event.userId());
+
+ try {
+ // 1. 조회수 증가 (기존 조회한 사람들)
+ cacheUpdateService.incrementArticleViewCount(
+ event.articleId(),
+ event.getDelta()
+ );
+
+ // 2. 조회한 사람 캐시에 추가 + 역인덱스 생성
+ cacheUpdateService.addArticleView(
+ event.viewId(),
+ event.userId(),
+ event.createdAt(),
+ event.articleId(),
+ event.source(),
+ event.sourceUrl(),
+ event.articleTitle(),
+ event.articlePublishedDate(),
+ event.articleSummary(),
+ event.articleCommentCount(),
+ event.articleViewCount()
+ );
+
+ log.info("[Listener] 기사 조회 캐시 업데이트 완료: articleId={}", event.articleId());
+
+ } catch (Exception e) {
+ log.error("[Listener] 기사 조회 캐시 업데이트 실패: articleId={}", event.articleId(), e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CacheSaveEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CacheSaveEventListener.java
new file mode 100644
index 0000000..b0ff91e
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CacheSaveEventListener.java
@@ -0,0 +1,37 @@
+package com.monew.monew_api.useractivity.listener;
+
+import com.monew.monew_api.useractivity.event.CacheSaveEvent;
+import com.monew.monew_api.useractivity.service.CacheUpdateService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+/**
+ * 캐시 저장 이벤트 리스너
+ * PostgreSQL 조회 후 MongoDB에 비동기 저장
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CacheSaveEventListener {
+
+ private final CacheUpdateService cacheUpdateService;
+
+ @Async
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ public void handle(CacheSaveEvent event) {
+ log.info("[Listener] 캐시 저장 이벤트 수신: userId={}", event.userId());
+
+ try {
+ cacheUpdateService.saveCache(
+ event.userId(),
+ event.data()
+ );
+ } catch (Exception e) {
+ log.error("[Listener] 캐시 저장 실패: userId={}", event.userId(), e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java
new file mode 100644
index 0000000..73bbcec
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java
@@ -0,0 +1,25 @@
+package com.monew.monew_api.useractivity.listener;
+
+import com.monew.monew_api.comments.event.CommentContentEditedEvent;
+import com.monew.monew_api.useractivity.service.CacheUpdateService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CommentContentEditedEventListener {
+
+ private final CacheUpdateService cacheUpdateService;
+
+ @Async
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ public void handle(CommentContentEditedEvent e) {
+ cacheUpdateService.updateCommentContent(e.commentId(), e.newContent());
+ log.info("[Listener] CommentContentEdited handled: commentId={}", e.commentId());
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentCreateEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentCreateEventListener.java
new file mode 100644
index 0000000..86d3ef6
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentCreateEventListener.java
@@ -0,0 +1,53 @@
+package com.monew.monew_api.useractivity.listener;
+
+import com.monew.monew_api.comments.event.CommentCreatedEvent;
+import com.monew.monew_api.useractivity.service.CacheUpdateService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+/**
+ * 댓글 작성 이벤트 리스너
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CommentCreateEventListener {
+
+ private final CacheUpdateService cacheUpdateService;
+
+ @Async
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ public void handle(CommentCreatedEvent event) {
+ log.info("[Listener] 댓글 작성 이벤트 수신: commentId={}, userId={}, articleId={}",
+ event.commentId(), event.userId(), event.articleId());
+
+ try {
+ // 1. 기사 댓글수 증가 (기존 조회한 사람들)
+ cacheUpdateService.incrementArticleCommentCount(
+ event.articleId(),
+ event.getDelta()
+ );
+
+ // 2. 작성자 캐시에 댓글 추가 + 역인덱스 생성
+ cacheUpdateService.addComment(
+ event.commentId(),
+ event.userId(),
+ event.userNickname(),
+ event.articleId(),
+ event.articleTitle(),
+ event.content(),
+ event.likeCount(),
+ event.createdAt()
+ );
+
+ log.info("[Listener] 댓글 작성 캐시 업데이트 완료: commentId={}", event.commentId());
+
+ } catch (Exception e) {
+ log.error("[Listener] 댓글 작성 캐시 업데이트 실패: commentId={}", event.commentId(), e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentDeletedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentDeletedEventListener.java
new file mode 100644
index 0000000..51a9c15
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentDeletedEventListener.java
@@ -0,0 +1,33 @@
+package com.monew.monew_api.useractivity.listener;
+
+import com.monew.monew_api.comments.event.CommentDeletedEvent;
+import com.monew.monew_api.useractivity.service.CacheUpdateService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+/**
+ * 댓글 삭제 이벤트 리스너
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CommentDeletedEventListener {
+
+ private final CacheUpdateService cacheUpdateService;
+
+ @Async
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ public void handle(CommentDeletedEvent event) {
+ log.info("[Listener] 댓글 삭제 이벤트 수신: commentId={}", event.commentId());
+
+ try {
+ cacheUpdateService.removeComment(event.commentId());
+ } catch (Exception e) {
+ log.error("[Listener] 댓글 삭제 캐시 처리 실패: commentId={}", event.commentId(), e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java
new file mode 100644
index 0000000..cc88a3d
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java
@@ -0,0 +1,43 @@
+package com.monew.monew_api.useractivity.listener;
+
+import com.monew.monew_api.comments.event.CommentLikedEvent;
+import com.monew.monew_api.useractivity.service.CacheUpdateService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+/**
+ * 댓글 좋아요 이벤트 리스너
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CommentLikedEventListener {
+
+ private final CacheUpdateService cacheUpdateService;
+
+ @Async
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ public void handle(CommentLikedEvent e) {
+ cacheUpdateService.addCommentLike(
+ e.likeId(),
+ e.likedByUserId(),
+ e.likeCreatedAt(),
+ e.commentId(),
+ e.articleId(),
+ e.articleTitle(),
+ e.commentAuthorId(),
+ e.commentUserNickname(),
+ e.commentContent(),
+ e.commentLikeCount(),
+ e.commentCreatedAt()
+ );
+ cacheUpdateService.updateCommentLikeCount(e.commentId(), +1);
+
+ log.info("[Listener] CommentLikedEvent handled: commentId={}, likedBy={}",
+ e.commentId(), e.likedByUserId());
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java
new file mode 100644
index 0000000..2cd67d6
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java
@@ -0,0 +1,31 @@
+package com.monew.monew_api.useractivity.listener;
+
+import com.monew.monew_api.comments.event.CommentUnlikedEvent;
+import com.monew.monew_api.useractivity.service.CacheUpdateService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+/**
+ * 댓글 좋아요 취소 이벤트 리스너
+ * 사용자가 댓글 좋아요를 취소했을 때 캐시 업데이트 수행
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CommentUnlikedEventListener {
+
+ private final CacheUpdateService cacheUpdateService;
+
+ @Async
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ public void handle(CommentUnlikedEvent e) {
+ cacheUpdateService.updateCommentLikeCount(e.commentId(), -1);
+ cacheUpdateService.removeCommentLike(e.likedByUserId(), e.commentId());
+ log.info("[Listener] CommentUnlikedEvent handled: commentId={}, likedBy={}",
+ e.commentId(), e.likedByUserId());
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java
new file mode 100644
index 0000000..50c662b
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java
@@ -0,0 +1,25 @@
+package com.monew.monew_api.useractivity.listener;
+
+import com.monew.monew_api.interest.event.InterestDeletedEvent;
+import com.monew.monew_api.useractivity.service.CacheUpdateService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class InterestDeletedEventListener {
+
+ private final CacheUpdateService cacheUpdateService;
+
+ @Async
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ public void handle(InterestDeletedEvent e) {
+ cacheUpdateService.removeInterest(e.interestId());
+ log.info("[Listener] InterestDeleted handled: interestId={}", e.interestId());
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestUpdateEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestUpdateEventListener.java
new file mode 100644
index 0000000..003c0fb
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestUpdateEventListener.java
@@ -0,0 +1,37 @@
+package com.monew.monew_api.useractivity.listener;
+
+import com.monew.monew_api.interest.event.InterestUpdatedEvent;
+import com.monew.monew_api.useractivity.service.CacheUpdateService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+/**
+ * Interest 정보 변경 이벤트 리스너
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class InterestUpdateEventListener {
+
+ private final CacheUpdateService cacheUpdateService;
+
+ @Async
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ public void handle(InterestUpdatedEvent event) {
+ log.info("[Listener] Interest 정보 변경 이벤트 수신: interestId={}",
+ event.interestId());
+
+ try {
+ cacheUpdateService.updateInterestKeyword(
+ event.interestId(),
+ event.newKeywords()
+ );
+ } catch (Exception e) {
+ log.error("[Listener] Interest 정보 캐시 업데이트 실패: interestId={}", event.interestId(), e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java
new file mode 100644
index 0000000..62e4f80
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java
@@ -0,0 +1,34 @@
+package com.monew.monew_api.useractivity.listener;
+
+import com.monew.monew_api.useractivity.service.CacheUpdateService;
+import com.monew.monew_api.subscribe.event.SubscriptionAddedEvent;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class SubscriptionAddedEventListener {
+
+ private final CacheUpdateService cacheUpdateService;
+
+ @Async
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ public void handle(SubscriptionAddedEvent e) {
+ cacheUpdateService.addSubscription(
+ e.userId(),
+ e.subscriptionId(),
+ e.interestId(),
+ e.interestName(),
+ e.interestKeywords(),
+ e.interestSubscriberCount(),
+ e.createdAt()
+ );
+ log.info("[Listener] SubscriptionAdded handled: userId={}, subId={}, interestId={}",
+ e.userId(), e.subscriptionId(), e.interestId());
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java
new file mode 100644
index 0000000..a9e13ff
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java
@@ -0,0 +1,26 @@
+package com.monew.monew_api.useractivity.listener;
+
+import com.monew.monew_api.useractivity.service.CacheUpdateService;
+import com.monew.monew_api.subscribe.event.SubscriptionRemovedEvent;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class SubscriptionRemovedEventListener {
+
+ private final CacheUpdateService cacheUpdateService;
+
+ @Async
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ public void handle(SubscriptionRemovedEvent e) {
+ cacheUpdateService.removeSubscription(e.userId(), e.subscriptionId(), e.interestId());
+ log.info("[Listener] SubscriptionRemoved handled: userId={}, subId={}",
+ e.userId(), e.subscriptionId());
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java
new file mode 100644
index 0000000..251a9ea
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java
@@ -0,0 +1,12 @@
+package com.monew.monew_api.useractivity.mapper;
+
+import com.monew.monew_api.useractivity.document.UserActivityCacheDocument;
+import com.monew.monew_api.useractivity.dto.UserActivityDto;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+
+@Mapper(componentModel = "spring")
+public interface UserActivityDocumentMapper {
+ @Mapping(target = "updatedAt", expression = "java(java.time.LocalDateTime.now())")
+ UserActivityCacheDocument toDocument(UserActivityDto dto);
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java
new file mode 100644
index 0000000..399bc6e
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java
@@ -0,0 +1,81 @@
+package com.monew.monew_api.useractivity.mapper;
+
+import com.monew.monew_api.comments.entity.Comment;
+import com.monew.monew_api.comments.entity.CommentLike;
+import com.monew.monew_api.user.User;
+import com.monew.monew_api.interest.entity.Interest;
+import com.monew.monew_api.subscribe.entity.Subscribe;
+import com.monew.monew_api.useractivity.document.UserActivityCacheDocument;
+import com.monew.monew_api.useractivity.dto.*;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Mapper(componentModel = "spring")
+public interface UserActivityMapper {
+
+ default UserActivityDto toUserActivityDto(
+ User user,
+ List subscriptions,
+ List comments,
+ List likes,
+ List views
+ ) {
+ return UserActivityDto.builder()
+ .id(String.valueOf(user.getId()))
+ .email(user.getEmail())
+ .nickname(user.getNickname())
+ .createdAt(user.getCreatedAt())
+ .subscriptions(toSubscriptionDtos(subscriptions))
+ .comments(toCommentDtos(comments))
+ .commentLikes(toCommentLikeDtos(likes))
+ .articleViews(views)
+ .build();
+ }
+
+ @Mapping(target = "id", expression = "java(String.valueOf(subscription.getId()))")
+ @Mapping(target = "interestId", expression = "java(String.valueOf(subscription.getInterest().getId()))")
+ @Mapping(target = "interestName", source = "interest.name")
+ @Mapping(target = "interestKeywords", expression = "java(mapKeywords(subscription.getInterest()))")
+ @Mapping(target = "interestSubscriberCount", source = "interest.subscriberCount")
+ @Mapping(target = "createdAt", source = "createdAt")
+ SubscribesActivityDto toSubscriptionDto(Subscribe subscription);
+
+ List toSubscriptionDtos(List subscriptions);
+
+ @Mapping(target = "id", expression = "java(String.valueOf(comment.getId()))")
+ @Mapping(target = "articleId", expression = "java(String.valueOf(comment.getArticle().getId()))")
+ @Mapping(target = "articleTitle", source = "article.title")
+ @Mapping(target = "userId", expression = "java(String.valueOf(comment.getUser().getId()))")
+ @Mapping(target = "userNickname", source = "user.nickname")
+ @Mapping(target = "content", source = "content")
+ @Mapping(target = "likeCount", source = "likeCount")
+ @Mapping(target = "createdAt", source = "createdAt")
+ CommentActivityDto toCommentDto(Comment comment);
+
+ List toCommentDtos(List comments);
+
+ @Mapping(target = "id", expression = "java(String.valueOf(commentLike.getId()))")
+ @Mapping(target = "createdAt", source = "createdAt")
+ @Mapping(target = "commentId", expression = "java(String.valueOf(commentLike.getComment().getId()))")
+ @Mapping(target = "articleId", expression = "java(String.valueOf(commentLike.getComment().getArticle().getId()))")
+ @Mapping(target = "articleTitle", source = "comment.article.title")
+ @Mapping(target = "commentUserId", source = "comment.user.id")
+ @Mapping(target = "commentUserNickname", source = "comment.user.nickname")
+ @Mapping(target = "commentContent", source = "comment.content")
+ @Mapping(target = "commentLikeCount", source = "comment.likeCount")
+ @Mapping(target = "commentCreatedAt", source = "comment.createdAt")
+ CommentLikeActivityDto toCommentLikeDto(CommentLike commentLike);
+
+ List toCommentLikeDtos(List commentLikes);
+
+ UserActivityDto toDto(UserActivityCacheDocument document);
+
+ default List mapKeywords(Interest interest) {
+ return interest.getKeywords().stream()
+ .map(ik -> ik.getKeyword().getKeyword())
+ .collect(Collectors.toList());
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 0000000..25626d8
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityRawMapper.java
@@ -0,0 +1,70 @@
+package com.monew.monew_api.useractivity.mapper;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.monew.monew_api.useractivity.dto.*;
+import com.monew.monew_api.useractivity.repository.projection.UserActivityRaw;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+import java.util.List;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class UserActivityRawMapper {
+
+ private final ObjectMapper objectMapper;
+
+ /**
+ * UserActivityRaw (Record) → UserActivityDto 변환
+ */
+ public UserActivityDto toDto(UserActivityRaw record) {
+ if (record == null) {
+ return null;
+ }
+
+ return UserActivityDto.builder()
+ .id(String.valueOf(record.id()))
+ .email(record.email())
+ .nickname(record.nickname())
+ .createdAt(record.createdAt())
+ .subscriptions(parseJsonList(
+ record.subscriptions(),
+ new TypeReference>() {}
+ ))
+ .comments(parseJsonList(
+ record.comments(),
+ new TypeReference>() {}
+ ))
+ .commentLikes(parseJsonList(
+ record.likes(),
+ new TypeReference>() {}
+ ))
+ .articleViews(parseJsonList(
+ record.views(),
+ new TypeReference>() {}
+ ))
+ .build();
+ }
+
+ /**
+ * JSON String → List 파싱
+ */
+ private List parseJsonList(String json, TypeReference> typeRef) {
+ if (json == null || json.isBlank() || "[]".equals(json.trim())) {
+ return Collections.emptyList();
+ }
+
+ try {
+ List result = objectMapper.readValue(json, typeRef);
+ return result != null ? result : Collections.emptyList();
+ } catch (JsonProcessingException e) {
+ log.error("JSON 파싱 실패: {}", json, e);
+ return Collections.emptyList();
+ }
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/ReverseIndexRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/ReverseIndexRepositoryImpl.java
new file mode 100644
index 0000000..9a92a9f
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/ReverseIndexRepositoryImpl.java
@@ -0,0 +1,50 @@
+package com.monew.monew_api.useractivity.repository.Impl;
+
+import com.monew.monew_api.useractivity.document.ReverseIndexDocument;
+import com.monew.monew_api.useractivity.repository.ReverseIndexCustomRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.data.mongodb.core.query.Criteria;
+import org.springframework.data.mongodb.core.query.Query;
+import org.springframework.data.mongodb.core.query.Update;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Repository
+@RequiredArgsConstructor
+public class ReverseIndexRepositoryImpl implements ReverseIndexCustomRepository {
+
+ private final MongoTemplate mongoTemplate;
+
+ @Override
+ public void addUser(String indexKey, String userId) {
+ Query q = Query.query(Criteria.where("_id").is(indexKey));
+ Update u = new Update()
+ .addToSet("userIds", userId)
+ .set("updatedAt", LocalDateTime.now());
+ mongoTemplate.upsert(q, u, ReverseIndexDocument.class);
+ }
+
+ @Override
+ public void removeUser(String indexKey, String userId) {
+ Query q = Query.query(Criteria.where("_id").is(indexKey));
+ Update u = new Update()
+ .pull("userIds", userId)
+ .set("updatedAt", LocalDateTime.now());
+ mongoTemplate.updateFirst(q, u, ReverseIndexDocument.class);
+ }
+
+ @Override
+ public Set findUserIdsByKeys(Set indexKeys) {
+ if (indexKeys.isEmpty()) return Collections.emptySet();
+ Query q = Query.query(Criteria.where("_id").in(indexKeys));
+ return mongoTemplate.find(q, ReverseIndexDocument.class)
+ .stream()
+ .flatMap(doc -> doc.getUserIds().stream())
+ .collect(Collectors.toSet());
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityCacheRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityCacheRepositoryImpl.java
new file mode 100644
index 0000000..ae69fbc
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityCacheRepositoryImpl.java
@@ -0,0 +1,197 @@
+package com.monew.monew_api.useractivity.repository.Impl;
+
+import com.monew.monew_api.useractivity.document.UserActivityCacheDocument;
+import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto;
+import com.monew.monew_api.useractivity.dto.CommentActivityDto;
+import com.monew.monew_api.useractivity.dto.CommentLikeActivityDto;
+import com.monew.monew_api.useractivity.dto.SubscribesActivityDto;
+import com.monew.monew_api.useractivity.repository.UserActivityCacheCustomRepository;
+import com.mongodb.BasicDBObject;
+import com.mongodb.client.result.UpdateResult;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.data.mongodb.core.query.Criteria;
+import org.springframework.data.mongodb.core.query.Query;
+import org.springframework.data.mongodb.core.query.Update;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Set;
+
+import static org.springframework.data.mongodb.core.query.Criteria.where;
+
+@Repository
+@RequiredArgsConstructor
+public class UserActivityCacheRepositoryImpl implements UserActivityCacheCustomRepository {
+
+ private final MongoTemplate mongo;
+
+ @Override
+ public long incCommentLikeCount(Set userIds, String commentId, int delta) {
+ if (userIds.isEmpty()) return 0;
+
+ var q1 = Query.query(where("_id").in(userIds).and("comments.id").is(commentId));
+ var u1 = new Update()
+ .inc("comments.$.likeCount", delta)
+ .set("updatedAt", LocalDateTime.now());
+ UpdateResult r1 = mongo.updateMulti(q1, u1, UserActivityCacheDocument.class);
+
+ var q2 = Query.query(where("_id").in(userIds).and("commentLikes.commentId").is(commentId));
+ var u2 = new Update().inc("commentLikes.$.commentLikeCount", delta)
+ .set("updatedAt", LocalDateTime.now());
+ UpdateResult r2 = mongo.updateMulti(q2, u2, UserActivityCacheDocument.class);
+
+ return r1.getModifiedCount() + r2.getModifiedCount();
+ }
+
+ @Override
+ public long incArticleViewCount(Set userIds, String articleId, int delta) {
+ if (userIds.isEmpty()) return 0;
+ var q = Query.query(where("_id").in(userIds).and("articleViews.articleId").is(articleId));
+ var u = new Update()
+ .inc("articleViews.$.articleViewCount", delta)
+ .set("updatedAt", LocalDateTime.now());
+ return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount();
+ }
+
+ @Override
+ public long incArticleCommentCount(Set userIds, String articleId, int delta) {
+ if (userIds.isEmpty()) return 0;
+ var q = Query.query(where("_id").in(userIds).and("articleViews.articleId").is(articleId));
+ var u = new Update()
+ .inc("articleViews.$.articleCommentCount", delta)
+ .set("updatedAt", LocalDateTime.now());
+ return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount();
+ }
+
+ @Override
+ public long pushCommentLike(String userId, CommentLikeActivityDto dto, int keepLatest) {
+ var q = Query.query(where("_id").is(userId));
+ var u = new Update()
+ .push("commentLikes")
+ .atPosition(0)
+ .slice(keepLatest)
+ .each(dto)
+ .set("updatedAt", LocalDateTime.now());
+ return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount();
+ }
+
+ @Override
+ public long pullCommentLike(String userId, String commentId) {
+ var q = Query.query(where("_id").is(userId));
+ var u = new Update()
+ .pull("commentLikes", new BasicDBObject("commentId", commentId))
+ .set("updatedAt", LocalDateTime.now());
+ return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount();
+ }
+
+ @Override
+ public long pushComment(String userId, CommentActivityDto dto, int keepLatest) {
+ var q = Query.query(where("_id").is(userId));
+ var u = new Update()
+ .push("comments")
+ .atPosition(0)
+ .slice(keepLatest)
+ .each(dto)
+ .set("updatedAt", LocalDateTime.now());
+ return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount();
+ }
+
+ @Override
+ public long updateCommentContentForUsers(Set userIds, String commentId, String newContent) {
+ if (userIds.isEmpty()) return 0;
+
+ var q1 = Query.query(where("_id").in(userIds).and("comments.id").is(commentId));
+ var u1 = new Update()
+ .set("comments.$.content", newContent)
+ .set("updatedAt", LocalDateTime.now());
+ var r1 = mongo.updateMulti(q1, u1, UserActivityCacheDocument.class);
+
+ var q2 = Query.query(where("_id").in(userIds).and("commentLikes.commentId").is(commentId));
+ var u2 = new Update()
+ .set("commentLikes.$[l].commentContent", newContent)
+ .set("updatedAt", LocalDateTime.now());
+ u2.filterArray(where("l.commentId").is(commentId));
+ var r2 = mongo.updateMulti(q2, u2, UserActivityCacheDocument.class);
+
+ return r1.getModifiedCount() + r2.getModifiedCount();
+ }
+
+
+ @Override
+ public long removeCommentEverywhere(Set userIds, String commentId) {
+ if (userIds.isEmpty()) return 0;
+ var q = Query.query(where("_id").in(userIds));
+ var u = new Update()
+ .pull("comments", new BasicDBObject("id", commentId))
+ .pull("commentLikes", new BasicDBObject("commentId", commentId))
+ .set("updatedAt", LocalDateTime.now());
+ return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount();
+ }
+
+ @Override
+ public long pushArticleView(String userId, ArticleViewActivityDto dto, int keepLatest) {
+ var q = Query.query(where("_id").is(userId));
+ var u = new Update()
+ .push("articleViews")
+ .atPosition(0)
+ .slice(keepLatest)
+ .each(dto)
+ .set("updatedAt", LocalDateTime.now());
+ return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount();
+ }
+
+ @Override
+ public long updateInterestKeywords(String interestId, List newKeywords) {
+ var q = Query.query(where("subscriptions.interestId").is(interestId));
+ var u = new Update()
+ .set("subscriptions.$[it].interestKeywords", newKeywords)
+ .set("updatedAt", LocalDateTime.now());
+ u.filterArray(where("it.interestId").is(interestId));
+ return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount();
+ }
+
+ @Override
+ public long removeInterestEverywhere(Set userIds, String interestId) {
+ if (userIds.isEmpty()) return 0;
+
+ Query q = Query.query(Criteria.where("_id").in(userIds));
+ Update u = new Update()
+ .pull("subscriptions", new BasicDBObject("interestId", interestId))
+ .set("updatedAt", LocalDateTime.now());
+
+ return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount();
+ }
+
+ @Override
+ public long addSubscription(String userId, SubscribesActivityDto dto) {
+ var q = Query.query(where("_id").is(userId));
+
+ if (dto.getId() != null) {
+ var pullExisting = new Update()
+ .pull("subscriptions", Query.query(where("id").is(dto.getId())).getQueryObject())
+ .set("updatedAt", LocalDateTime.now());
+ mongo.updateFirst(q, pullExisting, UserActivityCacheDocument.class);
+ }
+
+ var push = new Update()
+ .push("subscriptions")
+ .atPosition(0)
+ .slice(10)
+ .each(dto)
+ .set("updatedAt", LocalDateTime.now());
+
+ var result = mongo.updateFirst(q, push, UserActivityCacheDocument.class);
+ return result.getModifiedCount();
+ }
+
+ @Override
+ public long removeSubscription(String userId, String subscriptionId) {
+ var q = Query.query(where("_id").is(userId));
+ var u = new Update()
+ .pull("subscriptions", Query.query(where("id").is(subscriptionId)).getQueryObject())
+ .set("updatedAt", LocalDateTime.now());
+ return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount();
+ }
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityRepositoryImpl.java
new file mode 100644
index 0000000..7335e30
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityRepositoryImpl.java
@@ -0,0 +1,250 @@
+package com.monew.monew_api.useractivity.repository.Impl;
+
+import com.monew.monew_api.comments.entity.Comment;
+import com.monew.monew_api.comments.entity.CommentLike;
+import com.monew.monew_api.interest.entity.QKeyword;
+import com.monew.monew_api.subscribe.entity.Subscribe;
+import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto;
+import com.monew.monew_api.useractivity.repository.UserActivityRepository;
+import com.monew.monew_api.useractivity.repository.projection.UserActivityRaw;
+
+import com.querydsl.core.types.Projections;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.Query;
+import jakarta.persistence.Tuple;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+import java.sql.Timestamp;
+import java.util.List;
+
+import static com.monew.monew_api.article.entity.QArticle.article;
+import static com.monew.monew_api.article.entity.QArticleView.articleView;
+import static com.monew.monew_api.comments.entity.QComment.comment;
+import static com.monew.monew_api.comments.entity.QCommentLike.commentLike;
+import static com.monew.monew_api.user.QUser.user;
+import static com.monew.monew_api.interest.entity.QInterest.interest;
+import static com.monew.monew_api.interest.entity.QInterestKeyword.interestKeyword;
+import static com.monew.monew_api.subscribe.entity.QSubscribe.subscribe;
+
+
+@Repository
+@RequiredArgsConstructor
+public class UserActivityRepositoryImpl implements UserActivityRepository {
+
+ private final JPAQueryFactory queryFactory;
+ private final EntityManager entityManager;
+
+ @Override
+ public List findSubscriptionsByUserId(Long userId) {
+ return queryFactory
+ .selectFrom(subscribe)
+ .join(subscribe.interest, interest).fetchJoin()
+ .leftJoin(interest.keywords, interestKeyword).fetchJoin()
+ .leftJoin(interestKeyword.keyword, QKeyword.keyword1).fetchJoin()
+ .where(subscribe.user.id.eq(userId))
+ .distinct()
+ .fetch();
+ }
+
+ @Override
+ public List findRecentCommentsByUserId(Long userId) {
+ return queryFactory
+ .selectFrom(comment)
+ .join(comment.article, article).fetchJoin()
+ .join(comment.user, user).fetchJoin()
+ .where(
+ comment.user.id.eq(userId),
+ comment.deleted.isFalse(),
+ article.isDeleted.isFalse(),
+ user.deletedAt.isNull()
+ )
+ .orderBy(comment.createdAt.desc())
+ .limit(10)
+ .fetch();
+ }
+
+ @Override
+ public List findRecentLikesByUserId(Long userId) {
+ return queryFactory
+ .selectFrom(commentLike)
+ .join(commentLike.comment, comment).fetchJoin()
+ .join(comment.article, article).fetchJoin()
+ .join(comment.user, user).fetchJoin()
+ .where(
+ commentLike.user.id.eq(userId),
+ comment.deleted.eq(false),
+ article.isDeleted.eq(false),
+ user.deletedAt.isNull()
+ )
+ .orderBy(commentLike.createdAt.desc())
+ .limit(10)
+ .fetch();
+ }
+
+ @Override
+ public List findRecentViewsByUserId(Long userId) {
+ return queryFactory
+ .select(Projections.fields(
+ ArticleViewActivityDto.class,
+ articleView.id.stringValue().as("id"),
+ articleView.userId.stringValue().as("viewedBy"),
+ articleView.createdAt.as("createdAt"),
+ articleView.articleId.stringValue().as("articleId"),
+ article.source.as("source"),
+ article.sourceUrl.as("sourceUrl"),
+ article.title.as("articleTitle"),
+ article.publishDate.as("articlePublishedDate"),
+ article.summary.as("articleSummary"),
+ article.commentCount.as("articleCommentCount"),
+ article.viewCount.as("articleViewCount")
+ ))
+ .from(articleView)
+ .join(article).on(article.id.eq(articleView.articleId))
+ .where(
+ articleView.userId.eq(userId),
+ article.isDeleted.eq(false)
+ )
+ .orderBy(articleView.createdAt.desc())
+ .limit(10)
+ .fetch();
+ }
+
+ @Override
+ public UserActivityRaw findUserActivityRaw(Long userId) {
+ String sql = """
+ WITH recent_subscriptions AS (
+ SELECT
+ s.id AS subscription_id,
+ s.user_id,
+ s.created_at AS subscription_created_at,
+ i.id AS interest_id,
+ i.name AS interest_name,
+ i.subscriber_count,
+ STRING_AGG(k.keyword, ',') AS keywords
+ FROM subscribes s
+ JOIN interests i ON s.interest_id = i.id
+ LEFT JOIN interest_keywords ik ON i.id = ik.interest_id
+ LEFT JOIN keywords k ON ik.keyword_id = k.id
+ WHERE s.user_id = :userId
+ GROUP BY s.id, s.user_id, s.created_at, i.id, i.name, i.subscriber_count
+ ORDER BY s.created_at DESC
+ ),
+ recent_comments AS (
+ SELECT
+ c.id,
+ c.article_id,
+ c.user_id,
+ c.content,
+ c.like_count,
+ c.created_at,
+ a.title AS article_title,
+ u.nickname AS user_nickname
+ FROM comments c
+ JOIN articles a ON c.article_id = a.id
+ JOIN users u ON c.user_id = u.id
+ WHERE c.user_id = :userId
+ AND c.is_deleted = false
+ AND a.is_deleted = false
+ AND u.deleted_at IS NULL
+ ORDER BY c.created_at DESC
+ LIMIT 10
+ ),
+ recent_likes AS (
+ SELECT
+ cl.id,
+ cl.user_id,
+ cl.created_at,
+ cl.comment_id,
+ c.content AS comment_content,
+ c.like_count AS comment_like_count,
+ c.created_at AS comment_created_at,
+ c.user_id AS comment_user_id,
+ u.nickname AS comment_user_nickname,
+ a.id AS article_id,
+ a.title AS article_title
+ FROM comment_likes cl
+ JOIN comments c ON cl.comment_id = c.id
+ JOIN articles a ON c.article_id = a.id
+ JOIN users u ON c.user_id = u.id
+ WHERE cl.user_id = :userId
+ AND c.is_deleted = false
+ AND a.is_deleted = false
+ AND u.deleted_at IS NULL
+ ORDER BY cl.created_at DESC
+ LIMIT 10
+ ),
+ recent_views AS (
+ SELECT
+ av.id,
+ av.user_id,
+ av.created_at,
+ av.article_id,
+ a.source,
+ a.source_url,
+ a.title AS article_title,
+ a.publish_date,
+ a.summary,
+ a.comment_count,
+ a.view_count
+ FROM article_views av
+ JOIN articles a ON av.article_id = a.id
+ WHERE av.user_id = :userId
+ AND a.is_deleted = false
+ ORDER BY av.created_at DESC
+ LIMIT 10
+ )
+ SELECT
+ u.id as id,
+ u.email as email,
+ u.nickname as nickname,
+ u.created_at as createdAt,
+ COALESCE(
+ (SELECT jsonb_agg(to_jsonb(rs) ORDER BY rs.subscription_created_at DESC)
+ FROM recent_subscriptions rs WHERE rs.user_id = u.id),
+ '[]'::jsonb
+ )::text as subscriptions,
+ COALESCE(
+ (SELECT jsonb_agg(to_jsonb(rc) ORDER BY rc.created_at DESC)
+ FROM recent_comments rc WHERE rc.user_id = u.id),
+ '[]'::jsonb
+ )::text as comments,
+ COALESCE(
+ (SELECT jsonb_agg(to_jsonb(rl) ORDER BY rl.created_at DESC)
+ FROM recent_likes rl WHERE rl.user_id = u.id),
+ '[]'::jsonb
+ )::text as likes,
+ COALESCE(
+ (SELECT jsonb_agg(to_jsonb(rv) ORDER BY rv.created_at DESC)
+ FROM recent_views rv WHERE rv.user_id = u.id),
+ '[]'::jsonb
+ )::text as views
+ FROM users u
+ WHERE u.id = :userId
+ """;
+
+ Query query = entityManager.createNativeQuery(sql, Tuple.class);
+ query.setParameter("userId", userId);
+
+ @SuppressWarnings("unchecked")
+ List results = query.getResultList();
+
+ if (results.isEmpty()) {
+ return null;
+ }
+
+ Tuple tuple = results.get(0);
+
+ return new UserActivityRaw(
+ tuple.get("id", Long.class),
+ tuple.get("email", String.class),
+ tuple.get("nickname", String.class),
+ tuple.get("createdat", Timestamp.class).toLocalDateTime(),
+ tuple.get("subscriptions", String.class),
+ tuple.get("comments", String.class),
+ tuple.get("likes", String.class),
+ tuple.get("views", String.class)
+ );
+ }
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java
new file mode 100644
index 0000000..bd93e79
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java
@@ -0,0 +1,21 @@
+package com.monew.monew_api.useractivity.repository;
+
+import java.util.Set;
+
+public interface ReverseIndexCustomRepository {
+ /**
+ * 특정 인덱스 키에 사용자 ID 추가
+ * @param indexKey
+ * @param userId
+ */
+ void addUser(String indexKey, String userId);
+
+ /**
+ * 특정 인덱스 키에서 사용자 ID 제거
+ * @param indexKey
+ * @param userId
+ */
+ void removeUser(String indexKey, String userId);
+
+ Set findUserIdsByKeys(Set indexKeys);
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexRepository.java
new file mode 100644
index 0000000..8080a8d
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexRepository.java
@@ -0,0 +1,7 @@
+package com.monew.monew_api.useractivity.repository;
+
+import com.monew.monew_api.useractivity.document.ReverseIndexDocument;
+import org.springframework.data.mongodb.repository.MongoRepository;
+
+public interface ReverseIndexRepository extends MongoRepository, ReverseIndexCustomRepository {
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java
new file mode 100644
index 0000000..e837cc9
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java
@@ -0,0 +1,135 @@
+package com.monew.monew_api.useractivity.repository;
+
+import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto;
+import com.monew.monew_api.useractivity.dto.CommentActivityDto;
+import com.monew.monew_api.useractivity.dto.CommentLikeActivityDto;
+import com.monew.monew_api.useractivity.dto.SubscribesActivityDto;
+
+import java.util.List;
+import java.util.Set;
+
+public interface UserActivityCacheCustomRepository {
+ /**
+ * 댓글 좋아요 수 증감
+ *
+ * @param userIds 댓글 좋아요 수를 업데이트할 사용자 ID 집합
+ * @param commentId 댓글 ID
+ * @param delta 증감 값 (1, -1)
+ * @return 업데이트된 캐시 데이터 수
+ */
+ long incCommentLikeCount(Set userIds, String commentId, int delta);
+
+ /**
+ * 기사 조회수 증감
+ *
+ * @param userIds 기사 조회수을 업데이트할 사용자 ID 집합
+ * @param articleId 기사 ID
+ * @param delta 증감 값 (1)
+ * @return 업데이트된 캐시 데이터 수
+ */
+ long incArticleViewCount(Set userIds, String articleId, int delta);
+
+ /**
+ * 기사 댓글수 증감
+ *
+ * @param userIds 기사 댓글수를 업데이트할 사용자 ID 집합
+ * @param articleId 기사 ID
+ * @param delta 증감 값 (1)
+ * @return 업데이트된 캐시 데이터 수
+ */
+ long incArticleCommentCount(Set userIds, String articleId, int delta);
+
+ /**
+ * 댓글 좋아요 추가
+ *
+ * @param userId 사용자 ID
+ * @param dto 댓글 좋아요 활동 DTO
+ * @param keepLatest 유지할 최신 항목 수
+ * @return 업데이트된 캐시 데이터 수
+ */
+ long pushCommentLike(String userId, CommentLikeActivityDto dto, int keepLatest);
+
+ /**
+ * 댓글 좋아요 제거
+ *
+ * @param userId 사용자 ID
+ * @param commentId 댓글 ID
+ * @return 업데이트된 캐시 데이터 수
+ */
+ long pullCommentLike(String userId, String commentId);
+
+ /**
+ * 댓글 추가
+ *
+ * @param userId 사용자 ID
+ * @param dto 댓글 활동 DTO
+ * @param keepLatest 유지할 최신 항목 수
+ * @return 업데이트된 캐시 데이터 수
+ */
+ long pushComment(String userId, CommentActivityDto dto, int keepLatest);
+
+ /**
+ * 댓글 내용 수정
+ *
+ * @param userIds 사용자 ID 집합
+ * @param commentId 댓글 ID
+ * @param newContent 새로운 댓글 내용
+ * @return 업데이트된 캐시 데이터 수
+ */
+ long updateCommentContentForUsers(Set userIds, String commentId, String newContent);
+
+ /**
+ * 모든 사용자에 대해 댓글 제거
+ *
+ * @param userIds 사용자 ID 집합
+ * @param commentId 댓글 ID
+ * @return 업데이트된 캐시 데이터 수
+ */
+ long removeCommentEverywhere(Set userIds, String commentId);
+
+ /**
+ * 기사 조회 활동 추가
+ *
+ * @param userId 사용자 ID
+ * @param dto 기사 조회 활동 DTO
+ * @param keepLatest 유지할 최신 항목 수
+ * @return 업데이트된 캐시 데이터 수
+ */
+ long pushArticleView(String userId, ArticleViewActivityDto dto, int keepLatest);
+
+ /**
+ * Interest 키워드 업데이트
+ *
+ * @param interestId 관심사 ID
+ * @param newKeywords 새로운 키워드 리스트
+ * @return 업데이트된 캐시 데이터 수
+ */
+ long updateInterestKeywords(String interestId, List newKeywords);
+
+ /**
+ * 모든 사용자에 대해 Interest 제거
+ *
+ * @param userIds 사용자 ID 집합
+ * @param interestId 관심사 ID
+ * @return 업데이트된 캐시 데이터 수
+ */
+ long removeInterestEverywhere(Set userIds, String interestId);
+
+ /**
+ * 구독 추가
+ *
+ * @param userId 사용자 ID
+ * @param dto 구독 활동 DTO
+ * @return 업데이트된 캐시 데이터 수
+ */
+ long addSubscription(String userId, SubscribesActivityDto dto);
+
+ /**
+ * 구독 제거
+ *
+ * @param userId 사용자 ID
+ * @param subscriptionId 구독 ID
+ * @return 업데이트된 캐시 데이터 수
+ */
+ long removeSubscription(String userId, String subscriptionId);
+}
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java
new file mode 100644
index 0000000..e6e2e1d
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java
@@ -0,0 +1,9 @@
+package com.monew.monew_api.useractivity.repository;
+
+import com.monew.monew_api.useractivity.document.UserActivityCacheDocument;
+import org.springframework.data.mongodb.repository.MongoRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface UserActivityCacheRepository extends MongoRepository, UserActivityCacheCustomRepository {
+}
\ No newline at end of file
diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java
new file mode 100644
index 0000000..1421760
--- /dev/null
+++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java
@@ -0,0 +1,65 @@
+package com.monew.monew_api.useractivity.repository;
+
+/*
+TODO: Entity 클래스 완성 되면 import 수정
+ */
+
+import com.monew.monew_api.comments.entity.Comment;
+import com.monew.monew_api.comments.entity.CommentLike;
+import com.monew.monew_api.subscribe.entity.Subscribe;
+import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto;
+import com.monew.monew_api.useractivity.repository.projection.UserActivityRaw;
+
+import java.util.List;
+
+public interface UserActivityRepository {
+ /*
+ 활동 내역을 4개의 쿼리로 처리
+ UserActivityDto {
+ User
+ findSubscriptionsByUserId()
+ findRecentCommentsByUserId()
+ findRecentLikesByUserId()
+ findRecentViewsByUserId()
+ } 형태로 구성
+ */
+
+ /**
+ * 사용자의 구독 정보 조회
+ * @param userId 사용자 ID
+ * @return 구독 정보 리스트
+ */
+ List findSubscriptionsByUserId(Long userId);
+
+ /**
+ * 사용자의 최근 댓글 조회
+ * @param userId 사용자 ID
+ * @return 댓글 리스트
+ */
+ List