Skip to content

[feat] 뉴스 기사 백업 및 복구#91

Merged
Namsoo315 merged 54 commits intocodeit-monew:developfrom
yuhandemian:feature/#84
Nov 4, 2025
Merged

[feat] 뉴스 기사 백업 및 복구#91
Namsoo315 merged 54 commits intocodeit-monew:developfrom
yuhandemian:feature/#84

Conversation

@yuhandemian
Copy link
Copy Markdown
Collaborator

@yuhandemian yuhandemian commented Nov 3, 2025

Issues

✔️ Check-list

  • : Label을 지정해 주세요.
  • : Merge할 브랜치를 확인해 주세요.

🗒️ Work Description

뉴스 기사 삭제 및 복구 기능을 구현. 소프트 삭제(논리적 삭제), 하드 삭제(물리적 삭제), AWS S3 백업, 복원 기능, 요청 추적 및 로그 관리 기능

1️⃣ 소프트 삭제 (Soft Delete)

  • 엔드포인트: DELETE /api/articles/{articleId}
  • 기능: 기사를 논리적으로 삭제 (is_deleted = true, deleted_at 타임스탬프 기록)
  • 특징:
    • 실제 데이터는 DB에 보존되어 복구 가능
    • 삭제된 기사는 목록/검색에서 자동 제외 (@SQLRestriction 사용)
    • 연관된 댓글, 조회수 데이터는 유지
    • 멱등성 보장 (이미 삭제된 기사 재삭제 시 404 반환)

2️⃣ 하드 삭제 (Hard Delete)

  • 엔드포인트: DELETE /api/articles/{articleId}/hard
  • 기능: 기사 및 연관 데이터를 DB에서 완전히 삭제
  • Cascade 삭제 대상:
    • Comment (댓글)
    • CommentLike (댓글 좋아요)
    • ArticleView (조회수 기록)
  • 특징:
    • 트랜잭션으로 묶어 원자적 처리
    • 소프트 삭제된 기사도 하드 삭제 가능

3️⃣ 기사 복원 (Restore)

  • 엔드포인트: GET /api/articles/{articleId}/restore
  • 기능: 물리적 삭제된 기사를 복원
  • 동작:
    • deletedAt 필드를 null로 초기화
    • 복원된 기사는 즉시 목록/검색에 노출

4️⃣ S3 자동 백업 시스템

  • 백업 배치 작업 (매일 새벽 2시 실행):

    • 전날 기사를 JSON 형식으로 S3에 백업, 중복없이 백업
    • 파일명 규칙: deleted-articles-YYYY-MM-DD.json
    • S3 버킷: monew-article-backups
  • 로그 백업 배치 작업 (매일 새벽 1시 실행):

    • 애플리케이션 로그 파일을 GZIP 압축 후 S3에 업로드
    • 파일명 규칙: monew-application-YYYY-MM-DD.log.gz
    • S3 버킷: monew-bucket-s3-logback
    • 업로드 성공 시 로컬 로그 파일 자동 삭제
  • 수동 백업 트리거 API:

    • 기사 백업: POST /api/batch/trigger/article-backup
    • 로그 백업: POST /api/batch/trigger/log-backup

5️⃣ 요청 추적 및 감사 로깅

  • Request ID 필터:

    • 모든 HTTP 요청에 고유 Request ID 자동 부여
    • MDC(Mapped Diagnostic Context)에 Request ID, Client IP 기록
    • 응답 헤더에 X-Request-ID 포함
  • 감사 로그 (AuditLogger):

    • 모든 삭제/복원 작업 로깅
    • 로그 항목: Request ID, 사용자 ID, 기사 ID, 작업 유형, 타임스탬프
  • Logback 파일 로깅:

    • 일별 로그 파일 롤링 (monew-application-YYYY-MM-DD.log)
    • 최대 7일 보관, 총 크기 3GB 제한

📷 Screenshot

관심사 삭제 시 관련 기사 논리 삭제

image

관심사도 논리 삭제 됩니다.

image

로그 S3 백업 (로그 백업 수동 트리거 사용)

image image

S3에서 로그가 잘 저장 되었습니다.

image

뉴스 기사 백업 (수동트리거를 통해 테스트함)

수동트리거는 어제부터 오늘까지 기사 백업함( 기존 배치 스케줄러는 어제 기사만)

  1. 스케줄러 (매일 새벽 2시): triggerType=SCHEDULED → 전날 기사만 백업
  2. 수동 트리거 (POST /api/batch/trigger/article-backup): triggerType=MANUAL → 어제 + 오늘 기사 백업
image

S3에서 뉴스 기사가 잘 백업 되었습니다.

image

복구

복구를 하기 위해 논리삭제 자동 물리 삭제 트리거를 만들었습니다.

뉴스 기사 배치 자동 삭제 트리거 - 30일 전부터 오늘까지 ( 원래 30일 지나야 자동 삭제 됨)

논리적 삭제 기사들 자동 삭제

http://localhost:8080/api/batch/trigger/article-cleanup

기존 DB 논리삭제됨

image

삭제 트리거

image

DB에서 없어짐

image

기사 복구

image image

복구 완료

image image image

📚 Reference

Summary by CodeRabbit

  • 새로운 기능
    • 문서 소프트/하드 삭제 및 기간 지정 복구 API 추가
    • 일별 문서 백업·로그 백업 스케줄러 및 수동 트리거 추가
    • 요청별 고유 ID 응답 헤더와 감사 로그 기록 적용
    • 백업 복원 결과 반환 기능 추가
  • Chores
    • AWS S3 기반 백업 저장소 연동 및 배치 백업 파이프라인 도입
    • 파일 기반 로그 롤링 설정 및 파일 로그 출력 추가
  • 버그 수정
    • S3/백업 예외 처리 및 전역 예외 응답 보강
    • 관심사(Interest) 소프트 삭제 및 관련 게시물 정리 동작 개선

- Test.java 파일들 제거 (articleview, common/config)
- 불필요한 테스트 코드 정리
- AWS SDK BOM 2.29.32 버전 추가
- S3 클라이언트 라이브러리 추가
- 기사 백업 및 로그 백업을 위한 S3 연동 준비
- AWS S3 리전 및 버킷 설정 추가 (기사 백업용, 로그 백업용)
- AWS 자격 증명 환경 변수 설정
- 로그 파일 경로 및 보관 기간 설정 추가
- Spring Batch job 자동 실행 비활성화 (수동 트리거 활성화)
- 배치 트리거 설정 재배치
- 일별 로그 파일 롤링 정책 추가
- 로그 파일 경로 및 이름 동적 설정 (application.yml 연동)
- 최대 7일간 로그 파일 보관 설정
- 파일 로깅과 콘솔 로깅 동시 사용
- S3 로그 백업 배치를 위한 로그 파일 생성
- S3Client 빈 생성 및 설정
- AWS 리전 및 자격 증명 설정
- 기사 백업 및 로그 백업을 위한 S3 연동 기반 구성
- RequestIdFilter 구현 (UUID 기반 요청 ID 생성)
- MDC를 통한 로그 컨텍스트에 요청 정보 저장
- 요청 ID, HTTP 메서드, URI 추적 기능
- 배치 작업 및 API 요청 로그 추적 개선
- LoggingUtils 클래스 추가 (MDC 헬퍼 메서드)
- 요청 컨텍스트 정보 설정/조회/정리 기능
- 배치 작업 및 비동기 작업 로깅 지원
- ArticleBackupService 인터페이스 및 S3 구현체 추가
- LogBackupService 인터페이스 및 S3 구현체 추가
- S3 업로드, 다운로드, GZIP 압축/해제 기능
- 기사 데이터 JSON 백업 및 복원 기능
- 로그 파일 백업 및 로컬 정리 기능
- S3ServiceException 커스텀 예외 추가
- ArticleBackupBatchConfig 구현
- 전체 기사 데이터를 S3에 백업하는 배치 작업
- Chunk 기반 처리 (100개씩)
- ArticleBackupProcessor 및 ArticleBackupWriter 연동
- 배치 실행 로그 한글 메시지 적용
- LogBackupBatchConfig 구현
- 전날 로그 파일을 S3에 백업하는 Tasklet 배치
- GZIP 압축 후 S3 업로드
- 업로드 성공 시 로컬 파일 자동 삭제
- 배치 실행 로그 한글 메시지 적용
- ArticleBackupProcessor: 삭제된 기사를 S3 백업용 JSON으로 변환
- ArticleBackupWriter: 배치 처리된 기사 백업을 S3에 업로드
- ArticleBackupScheduler: 매일 새벽 2시 삭제된 기사 자동 백업 실행
- LogBackupScheduler: 매일 새벽 1시 로그 파일 자동 백업 실행
- deletedAt 필드 추가로 소프트 삭제 지원
- @where 애너테이션으로 삭제되지 않은 기사만 기본 조회
- @SQLDelete 애너테이션으로 DELETE 쿼리를 UPDATE로 자동 변환
- findAllByDeletedAtIsNotNull: 삭제된 기사 조회 (백업용)
- hardDeleteById: 물리적 삭제 쿼리
- restoreById: 소프트 삭제 복원 쿼리
- softDeleteArticle: 소프트 삭제
- hardDeleteArticle: 물리적 삭제
- restoreArticle: 삭제된 기사 복원
- softDeleteArticle: deletedAt 설정 후 댓글/조회수 cascade 삭제
- hardDeleteArticle: DB에서 완전 삭제 (복원 불가)
- restoreArticle: deletedAt 초기화로 복원
- 각 작업마다 AuditLogger로 감사 로그 기록
- DELETE /api/articles/{id}: 소프트 삭제
- DELETE /api/articles/{id}/hard: 물리적 삭제
- POST /api/articles/{id}/restore: 삭제 복원
- Swagger 문서화 포함
- deleteByArticleId: 기사 삭제 시 연관된 댓글 자동 삭제
- @Modifying @transactional로 벌크 삭제 지원
- deleteByArticleId: 기사 삭제 시 연관된 조회수 자동 삭제
- @Modifying @transactional로 벌크 삭제 지원
- POST /api/batch/trigger/log-backup: 로그 백업 수동 실행
- Swagger 문서화 포함
- triggerLogBackupJob: 로그 백업 배치 작업 수동 실행
- 기존 기사 백업 Job과 동일한 패턴으로 구현
- /api/batch/trigger/** 경로 인증 없이 허용
- 개발/테스트 환경에서 배치 작업 수동 실행 지원
- BackupException: 500 Internal Server Error 응답
- 백업 실패 시 명확한 에러 메시지 반환
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Nov 3, 2025

Walkthrough

기사·로그의 S3 기반 백업/복구 및 관련 배치(Job/Step/Processor/Writer)/스케줄러/트리거와 소프트/하드 삭제·복구 로직, 요청 ID 필터·감사 로깅·예외 처리, S3 클라이언트 구성, 로그 파일 롤링·스키마·엔티티 soft-delete가 추가되었습니다.

Changes

Cohort / File(s) 변경 요약
빌드·환경·로깅
monew/build.gradle, monew/src/main/resources/application.yml, monew/src/main/resources/logback-spring.xml
AWS S3 SDK BOM·s3 의존성 추가, aws.s3·logging 설정과 FILE appender·롤링 설정 추가
AWS S3 구성
monew/src/main/java/.../common/config/AwsS3Config.java
S3Client 빈 추가(리전, 선택적 StaticCredentialsProvider, destroyMethod="close")
백업 DTO·예외
monew/src/main/java/.../backup/dto/ArticleBackupDto.java, .../backup/exception/BackupNotFoundException.java, .../backup/exception/S3ServiceException.java
ArticleBackupDto 레코드 추가, BackupNotFoundException·S3ServiceException 정의
백업 서비스 인터페이스
monew/src/main/java/.../backup/service/S3BackupService.java, .../backup/service/LogBackupService.java
S3BackupService·LogBackupService 인터페이스 선언 (upload/download/exists 등)
백업 서비스 구현
monew/src/main/java/.../backup/service/impl/S3BackupServiceImpl.java, .../S3LogBackupServiceImpl.java
S3에 gzip JSON 업로드/다운로드·headObject 존재확인·예외 매핑·로컬 로그 업로드/정리 구현
배치 구성·처리·쓰기
monew/src/main/java/.../batch/config/ArticleBackupBatchConfig.java, .../LogBackupBatchConfig.java, .../processor/ArticleBackupProcessor.java, .../writer/ArticleBackupWriter.java
JpaPagingItemReader, ArticleBackupProcessor, Chunk 집계 후 S3 업로드 Writer, 로그 백업 Tasklet/Step 추가
배치 스케줄러·서비스·컨트롤러
monew/src/main/java/.../batch/scheduler/*, .../batch/service/BatchJobService.java, .../batch/controller/BatchJobController.java
일간 스케줄러 및 수동 트리거 엔드포인트·서비스 메서드 추가(기사/로그 백업·정리 트리거)
기사 API·서비스 변경
monew/src/main/java/.../article/controller/ArticleController.java, .../article/service/ArticleService.java, .../article/service/impl/ArticleServiceImpl.java
softDelete/hardDelete/restore 엔드포인트 및 복구 로직(기간 검증, S3 다운로드·직렬화, 중복 필터링, 복원)과 감사 로깅·트랜잭션 처리 추가
도메인·리포지토리 변경
monew/src/main/java/.../article/domain/Article.java, .../article/repository/ArticleRepository.java, .../articleview/repository/ArticleViewRepository.java, .../comment/repository/CommentRepository.java
Article.restore 팩토리 메서드 추가, 하드 삭제·포함 조회·소프트삭제 조회·뷰·댓글 관련 네이티브 쿼리 추가
엔티티 soft-delete·스키마 변경
monew/src/main/java/.../interest/domain/Interest.java, monew/src/main/resources/schema.sql, monew/src/main/resources/schema-h2.sql
Interest에 @SQLDelete/@SQLRestriction·updated_at/is_deleted/deleted_at 추가, articles FK ON DELETE CASCADE 제거, notifications 인덱스 추가
공통 필터·로깅·예외 처리·감사
monew/src/main/java/.../common/filter/RequestIdFilter.java, .../common/logging/AuditLogger.java, .../common/exception/GlobalExceptionHandler.java, .../auth/config/SecurityConfig.java
RequestIdFilter(MDC·응답 헤더) 추가, AuditLogger 추가, BackupNotFoundException·S3ServiceException 핸들러 추가, SecurityConfig에 필터 등록
정리 스케줄러
monew/src/main/java/.../batch/scheduler/ArticleCleanupScheduler.java
소프트 삭제된 기사 하드 삭제 스케줄러 및 수동 실행 메서드 추가
테스트·불필요 파일 삭제
monew/src/test/java/.../interest/*, monew/src/main/java/.../common/config/Test.java, monew/src/main/java/.../articleview/controller/Test.java
Interest 테스트에 ArticleRepository 모킹/통합 검증 추가, 불필요한 Test 파일 삭제

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant Client
    participant Ctrl as ArticleController
    participant Svc as ArticleServiceImpl
    participant Repo as ArticleRepository
    participant CRepo as CommentRepository
    participant VRepo as ArticleViewRepository
    participant S3 as S3BackupService
    participant Audit as AuditLogger

    rect rgb(230,248,255)
    Client->>Ctrl: DELETE /api/articles/{id}
    Ctrl->>Svc: softDeleteArticle(articleId, user)
    Svc->>Repo: mark soft-deleted (update is_deleted)
    Svc->>Audit: logSoftDelete(...)
    Ctrl-->>Client: 204
    end

    rect rgb(255,244,230)
    Client->>Ctrl: DELETE /api/articles/{id}/hard
    Ctrl->>Svc: hardDeleteArticle(articleId, user)
    Svc->>CRepo: deleteCommentLikesByArticleId(articleId)
    Svc->>CRepo: deleteByArticleId(articleId)
    Svc->>VRepo: deleteByArticleId(articleId)
    Svc->>Repo: hardDelete(articleId) (physical DELETE)
    Svc->>Audit: logHardDelete(...)
    Ctrl-->>Client: 204
    end

    rect rgb(240,255,240)
    Client->>Ctrl: GET /api/articles/restore?from=..&to=..
    Ctrl->>Svc: restoreArticlesFromBackup(from,to,user)
    loop per date
      Svc->>S3: backupExists(date)
      alt exists
        Svc->>S3: downloadBackup(date)
        Svc->>Repo: findAllSourceUrls()/existsIncludingDeleted(...)
        alt missing
          Svc->>Svc: restoreArticle(dto) -> Repo.save(...)
        end
      else not found
        Svc->>Audit: logRestoreFailure(...)
      end
    end
    Svc->>Audit: logRestore(...)
    Svc-->>Ctrl: ArticleRestoreResultDto
    Ctrl-->>Client: 200
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

주의 집중 영역:

  • ArticleServiceImpl.restoreArticlesFromBackup: 날짜 검증, S3 다운로드·직렬화, 부분 실패 처리, Interest 복원/생성 및 Article 재구성
  • S3BackupServiceImpl / S3LogBackupServiceImpl: gzip 처리, S3 키·버킷 구성, 예외 매핑(BackupNotFoundException/S3ServiceException)
  • hardDelete 트랜잭션 범위 및 여러 리포지토리 연쇄 삭제 정합성
  • 배치(Job/Step/Reader/Writer)와 StepExecutionContext 집계/업로드 시점

Possibly related PRs

Suggested reviewers

  • kimyumin03
  • Namsoo315

Pre-merge checks and finishing touches

✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목 '[feat] 뉴스 기사 백업 및 복구'는 변경 사항의 핵심 내용(기사 백업 및 복구 기능 구현)을 명확하고 간결하게 요약하고 있습니다.
Description check ✅ Passed PR 설명이 제공된 템플릿 구조를 준수하며, Issues, Work Description, Screenshot, Reference 섹션을 포함하고 있습니다. 상세한 구현 내용과 스크린샷으로 기능을 충분히 설명했습니다.
Linked Issues check ✅ Passed 코드 변경 사항이 연결된 이슈 #84의 모든 주요 요구 사항을 충족합니다: 소프트/하드 삭제, 복원 기능, S3 백업, 요청 추적, 감사 로깅 등이 모두 구현되었습니다.
Out of Scope Changes check ✅ Passed 모든 변경 사항이 이슈 #84의 요구 범위 내에 있습니다. 소프트/하드 삭제, 백업/복구, 요청 추적, 감사 로깅 등 모든 기능이 명시된 목표와 일치합니다.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🧹 Nitpick comments (5)
monew/src/main/java/com/spring/monew/common/config/AwsS3Config.java (1)

25-29: region 유효성 검증과 DefaultCredentialsProvider를 명시적으로 사용하는 것을 권장합니다.

  1. Region.of(region) 호출 시 유효하지 않은 region 문자열이 입력되면 IllegalArgumentException이 발생할 수 있습니다. 애플리케이션 시작 시 명확한 에러 메시지를 제공하기 위해 region 유효성을 검증하는 것이 좋습니다.

  2. credentials가 없을 때 암묵적으로 DefaultCredentialsProvider가 사용되지만, 명시적으로 설정하면 코드 의도가 더 명확해집니다.

다음과 같이 개선할 수 있습니다:

+import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
+
   @Bean
   public S3Client s3Client() {
+    // region 유효성 검증
+    Region awsRegion;
+    try {
+      awsRegion = Region.of(region);
+    } catch (IllegalArgumentException e) {
+      throw new IllegalArgumentException("유효하지 않은 AWS region: " + region, e);
+    }
+
     if (accessKey == null || accessKey.isEmpty() || secretKey == null || secretKey.isEmpty()) {
+      // 명시적으로 DefaultCredentialsProvider 사용 (IAM Role, 환경 변수 등)
       return S3Client.builder()
-          .region(Region.of(region))
+          .region(awsRegion)
+          .credentialsProvider(DefaultCredentialsProvider.create())
           .build();
     }
 
     AwsBasicCredentials awsCredentials = AwsBasicCredentials.create(accessKey, secretKey);
     return S3Client.builder()
-        .region(Region.of(region))
+        .region(awsRegion)
         .credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
         .build();
   }
monew/src/main/java/com/spring/monew/common/filter/RequestIdFilter.java (1)

41-43: 분산 추적을 위한 기존 Request ID 수용 고려

현재는 항상 새로운 Request ID를 생성하지만, 클라이언트나 API Gateway에서 이미 X-Request-ID 헤더를 전달한 경우 이를 재사용하면 분산 시스템 전체에서 요청을 추적하기 더 용이합니다.

다음과 같이 개선할 수 있습니다:

-    private String generateRequestId() {
-        return REQUEST_ID_PREFIX + UUID.randomUUID().toString();
+    private String generateRequestId(HttpServletRequest request) {
+        String existingRequestId = request.getHeader(REQUEST_ID_HEADER);
+        if (existingRequestId != null && !existingRequestId.isBlank()) {
+            return existingRequestId;
+        }
+        return REQUEST_ID_PREFIX + UUID.randomUUID().toString();
     }

그리고 호출부를 수정:

-        String requestId = generateRequestId();
+        String requestId = generateRequestId(request);
monew/src/main/java/com/spring/monew/batch/processor/ArticleBackupProcessor.java (1)

18-27: 배치 프로세서 구현이 적절합니다.

ArticleArticleBackupDto로 변환하는 로직이 명확하며, 예외 처리도 Spring Batch의 재시도/스킵 메커니즘과 잘 통합됩니다.

대량 백업 시 디버그 로그가 과도할 수 있습니다. 프로덕션 환경에서 로그 레벨을 조정하거나, 청크 단위로 한 번만 로깅하는 것을 고려하세요:

-            log.debug("기사 {} 백업 준비 완료", article.getId());
+            // 필요시 청크 레벨에서 로깅 (Writer에서 처리)
monew/src/main/java/com/spring/monew/batch/scheduler/ArticleBackupScheduler.java (1)

34-36: 배치 실패 모니터링 강화를 고려하세요.

현재 예외를 로그로만 기록하고 있습니다. 배치 작업이 반복적으로 실패할 경우를 대비하여 알림 메커니즘(예: 메트릭 발행, 알림 전송)을 추가하는 것을 권장합니다.

monew/src/main/java/com/spring/monew/batch/writer/ArticleBackupWriter.java (1)

24-32: 청크 처리 로직을 단순화할 수 있습니다.

Line 26의 명시적 캐스팅과 Line 27의 null 필터는 불필요합니다. ArticleBackupProcessor가 null을 반환하지 않으므로 방어 코드가 과도합니다.

다음과 같이 단순화할 수 있습니다:

     @Override
     public void write(Chunk<? extends ArticleBackupDto> chunk) {
-        List<ArticleBackupDto> articles = chunk.getItems().stream()
-            .map(dto -> (ArticleBackupDto) dto)
-            .filter(dto -> dto != null)
-            .toList();
+        List<ArticleBackupDto> articles = chunk.getItems();
 
         if (articles.isEmpty()) {
             return;
         }
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 646f76c and 803267f.

📒 Files selected for processing (32)
  • monew/build.gradle (1 hunks)
  • monew/src/main/java/com/spring/monew/article/controller/ArticleController.java (3 hunks)
  • monew/src/main/java/com/spring/monew/article/domain/Article.java (1 hunks)
  • monew/src/main/java/com/spring/monew/article/repository/ArticleRepository.java (1 hunks)
  • monew/src/main/java/com/spring/monew/article/service/ArticleService.java (2 hunks)
  • monew/src/main/java/com/spring/monew/article/service/impl/ArticleServiceImpl.java (3 hunks)
  • monew/src/main/java/com/spring/monew/articleview/controller/Test.java (0 hunks)
  • monew/src/main/java/com/spring/monew/articleview/repository/ArticleViewRepository.java (2 hunks)
  • monew/src/main/java/com/spring/monew/auth/config/SecurityConfig.java (1 hunks)
  • monew/src/main/java/com/spring/monew/backup/dto/ArticleBackupDto.java (1 hunks)
  • monew/src/main/java/com/spring/monew/backup/exception/BackupNotFoundException.java (1 hunks)
  • monew/src/main/java/com/spring/monew/backup/exception/S3ServiceException.java (1 hunks)
  • monew/src/main/java/com/spring/monew/backup/service/LogBackupService.java (1 hunks)
  • monew/src/main/java/com/spring/monew/backup/service/S3BackupService.java (1 hunks)
  • monew/src/main/java/com/spring/monew/backup/service/impl/S3BackupServiceImpl.java (1 hunks)
  • monew/src/main/java/com/spring/monew/backup/service/impl/S3LogBackupServiceImpl.java (1 hunks)
  • monew/src/main/java/com/spring/monew/batch/config/ArticleBackupBatchConfig.java (1 hunks)
  • monew/src/main/java/com/spring/monew/batch/config/LogBackupBatchConfig.java (1 hunks)
  • monew/src/main/java/com/spring/monew/batch/controller/BatchJobController.java (1 hunks)
  • monew/src/main/java/com/spring/monew/batch/processor/ArticleBackupProcessor.java (1 hunks)
  • monew/src/main/java/com/spring/monew/batch/scheduler/ArticleBackupScheduler.java (1 hunks)
  • monew/src/main/java/com/spring/monew/batch/scheduler/LogBackupScheduler.java (1 hunks)
  • monew/src/main/java/com/spring/monew/batch/service/BatchJobService.java (2 hunks)
  • monew/src/main/java/com/spring/monew/batch/writer/ArticleBackupWriter.java (1 hunks)
  • monew/src/main/java/com/spring/monew/comment/repository/CommentRepository.java (1 hunks)
  • monew/src/main/java/com/spring/monew/common/config/AwsS3Config.java (1 hunks)
  • monew/src/main/java/com/spring/monew/common/config/Test.java (0 hunks)
  • monew/src/main/java/com/spring/monew/common/exception/GlobalExceptionHandler.java (2 hunks)
  • monew/src/main/java/com/spring/monew/common/filter/RequestIdFilter.java (1 hunks)
  • monew/src/main/java/com/spring/monew/common/logging/AuditLogger.java (1 hunks)
  • monew/src/main/resources/application.yml (3 hunks)
  • monew/src/main/resources/logback-spring.xml (1 hunks)
💤 Files with no reviewable changes (2)
  • monew/src/main/java/com/spring/monew/common/config/Test.java
  • monew/src/main/java/com/spring/monew/articleview/controller/Test.java
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2025-10-30T04:47:15.305Z
Learnt from: Namsoo315
Repo: codeit-monew/sb05-monew-team7 PR: 59
File: monew/src/main/java/com/spring/monew/comment/repository/CommentRepository.java:29-30
Timestamp: 2025-10-30T04:47:15.305Z
Learning: In the file monew/src/main/java/com/spring/monew/comment/repository/CommentRepository.java, the method `findIncludingDeleted(Param("commentId") UUID commentId)` is intentionally named this way even though the query `SELECT * FROM comments c WHERE c.id = :commentId AND c.is_deleted = true` only returns deleted records (is_deleted = true), not all records including deleted ones. This naming is by design and should not be flagged.

Applied to files:

  • monew/src/main/java/com/spring/monew/articleview/repository/ArticleViewRepository.java
  • monew/src/main/java/com/spring/monew/article/repository/ArticleRepository.java
  • monew/src/main/java/com/spring/monew/comment/repository/CommentRepository.java
📚 Learning: 2025-10-31T05:05:50.992Z
Learnt from: yuhandemian
Repo: codeit-monew/sb05-monew-team7 PR: 69
File: monew/src/main/java/com/spring/monew/articleview/service/impl/ArticleViewServiceImpl.java:62-68
Timestamp: 2025-10-31T05:05:50.992Z
Learning: In ArticleViewServiceImpl.trackView(), the implementation intentionally distinguishes between view count (Article.viewCount) and view records (ArticleView in PostgreSQL, ActivityArticleViewDoc in MongoDB). View count increments at most once per user per 24 hours (Redis-controlled deduplication), while view records track all view events including duplicates within 24 hours for user activity history. When Redis indicates a duplicate but no recent DB record exists (edge case), the code correctly creates a new view record WITHOUT incrementing the count, respecting Redis as the source of truth for deduplication.

Applied to files:

  • monew/src/main/java/com/spring/monew/articleview/repository/ArticleViewRepository.java
  • monew/src/main/java/com/spring/monew/article/domain/Article.java
📚 Learning: 2025-10-29T09:36:40.948Z
Learnt from: Namsoo315
Repo: codeit-monew/sb05-monew-team7 PR: 57
File: monew/src/test/java/com/spring/monew/comment/repository/CommentRepositoryTest.java:175-197
Timestamp: 2025-10-29T09:36:40.948Z
Learning: In the file monew/src/test/java/com/spring/monew/comment/repository/CommentRepositoryTest.java, the content strings like "like0", "like5", "like3" in the sortByLikeCount test are just comment content labels, not indicators of the actual like counts. The actual like counts are controlled by incrementLikeCount() calls.

Applied to files:

  • monew/src/main/java/com/spring/monew/comment/repository/CommentRepository.java
📚 Learning: 2025-10-31T04:05:41.435Z
Learnt from: yuhandemian
Repo: codeit-monew/sb05-monew-team7 PR: 69
File: monew/src/main/java/com/spring/monew/activity/domain/ActivityCommentDoc.java:20-28
Timestamp: 2025-10-31T04:05:41.435Z
Learning: In the file monew/src/main/java/com/spring/monew/activity/domain/ActivityCommentDoc.java, the ActivityCommentDoc class intentionally does not include a commentId field because it is a denormalized MongoDB document designed to store snapshot data for user activity history. It is used to fetch comments, likes, and recently viewed articles in a single query without joins, so linking back to the original comment entity is not required.

Applied to files:

  • monew/src/main/java/com/spring/monew/comment/repository/CommentRepository.java
📚 Learning: 2025-10-28T02:10:38.287Z
Learnt from: yuhandemian
Repo: codeit-monew/sb05-monew-team7 PR: 37
File: monew/src/main/java/com/spring/monew/comment/controller/CommentController.java:57-65
Timestamp: 2025-10-28T02:10:38.287Z
Learning: The monew project currently does not implement Spring Security authentication - SecurityConfig exists but uses .anyRequest().permitAll(). All controllers use RequestHeader(name = "Monew-Request-User-ID") UUID userId to receive user identification, not AuthenticationPrincipal.

Applied to files:

  • monew/src/main/java/com/spring/monew/auth/config/SecurityConfig.java
🧬 Code graph analysis (10)
monew/src/main/java/com/spring/monew/batch/writer/ArticleBackupWriter.java (1)
monew/src/main/java/com/spring/monew/batch/processor/ArticleBackupProcessor.java (1)
  • Component (11-28)
monew/src/main/java/com/spring/monew/batch/config/LogBackupBatchConfig.java (1)
monew/src/main/java/com/spring/monew/auth/config/SecurityConfig.java (1)
  • RequiredArgsConstructor (17-56)
monew/src/main/java/com/spring/monew/batch/processor/ArticleBackupProcessor.java (1)
monew/src/main/java/com/spring/monew/batch/writer/ArticleBackupWriter.java (1)
  • Component (15-46)
monew/src/main/java/com/spring/monew/backup/service/impl/S3LogBackupServiceImpl.java (2)
monew/src/main/java/com/spring/monew/backup/exception/S3ServiceException.java (1)
  • S3ServiceException (3-11)
monew/src/main/java/com/spring/monew/backup/service/impl/S3BackupServiceImpl.java (1)
  • Slf4j (24-152)
monew/src/main/java/com/spring/monew/common/exception/GlobalExceptionHandler.java (2)
monew/src/main/java/com/spring/monew/backup/exception/BackupNotFoundException.java (1)
  • BackupNotFoundException (5-21)
monew/src/main/java/com/spring/monew/backup/exception/S3ServiceException.java (1)
  • S3ServiceException (3-11)
monew/src/main/java/com/spring/monew/article/service/impl/ArticleServiceImpl.java (2)
monew/src/main/java/com/spring/monew/article/exception/ArticleNotFoundException.java (1)
  • ArticleNotFoundException (5-9)
monew/src/main/java/com/spring/monew/common/exception/GlobalExceptionHandler.java (1)
  • Slf4j (17-107)
monew/src/main/java/com/spring/monew/batch/scheduler/LogBackupScheduler.java (1)
monew/src/main/java/com/spring/monew/batch/scheduler/ArticleBackupScheduler.java (1)
  • Component (14-38)
monew/src/main/java/com/spring/monew/backup/service/impl/S3BackupServiceImpl.java (3)
monew/src/main/java/com/spring/monew/backup/exception/BackupNotFoundException.java (1)
  • BackupNotFoundException (5-21)
monew/src/main/java/com/spring/monew/backup/exception/S3ServiceException.java (1)
  • S3ServiceException (3-11)
monew/src/main/java/com/spring/monew/backup/service/impl/S3LogBackupServiceImpl.java (1)
  • Slf4j (21-147)
monew/src/main/java/com/spring/monew/batch/scheduler/ArticleBackupScheduler.java (2)
monew/src/main/java/com/spring/monew/auth/config/SecurityConfig.java (1)
  • RequiredArgsConstructor (17-56)
monew/src/main/java/com/spring/monew/batch/scheduler/LogBackupScheduler.java (1)
  • Component (14-38)
monew/src/main/java/com/spring/monew/batch/config/ArticleBackupBatchConfig.java (1)
monew/src/main/java/com/spring/monew/auth/config/SecurityConfig.java (1)
  • RequiredArgsConstructor (17-56)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build-and-test
🔇 Additional comments (22)
monew/src/main/resources/logback-spring.xml (3)

12-13: ✅ LOG_PATTERN 속성이 파일 로깅용으로 적절하게 구성되었습니다.

콘솔 로깅의 LOG_PATTERN_COLOR와 달리 색상 정보 없이 순수 로그 정보만 포함되어 파일 저장에 적합합니다. MDC 정보(requestId, requestMethod, requestUri)도 올바르게 포함되어 있습니다.


15-17: 📋 로그 파일 경로 설정 확인 필요합니다.

기본값 ./logs는 개발 환경에서는 적절하지만, ECS/컨테이너 환경에서 로그가 정상 저장되는지 확인이 필요합니다. 특히 S3 백업 배치(logback-spring.xml 파일명 패턴 monew-application-YYYY-MM-DD.log.gz)와 연동되는 부분이므로, 배치 작업이 올바른 경로에서 로그 파일을 감지하는지 검증해주세요.


26-37: ✅ RollingFileAppender 기본 구조는 적절합니다.

일별 롤링 및 로그 파일 분리가 잘 설계되어 있으나, 위에서 지적한 maxHistorytotalSizeCap 설정이 요구사항과 일치하는지 확인이 필요합니다.

monew/src/main/resources/application.yml (2)

37-42: 배치 작업 활성화 방식 확인 필요.

spring.batch.job.enabled: false로 설정하면 Spring Batch Job이 자동 시작되지 않습니다. PR 요약에서 "기사 백업 배치(매일 02:00), 로그 백업 배치(매일 01:00)"에 자동 실행이 필요하다고 명시되어 있는데, 이것이 @Scheduled 애너테이션으로 구현되었는지, 아니면 다른 스케줄링 메커니즘으로 구현되었는지 확인이 필요합니다.

  • 만약 Spring Batch Job으로 배치를 구현했다면, 본 설정은 자동 실행을 방해할 것입니다.
  • 만약 @Scheduled 방식으로 구현했다면, 이 설정은 문제없습니다.

108-114: AWS 자격증명 설정 확인 및 문서화 필요.

AWS access-keysecret-key의 기본값이 빈 문자열("")로 설정되어 있습니다. 로컬 개발 환경에서는 문제가 없을 수 있으나, 스테이징/프로덕션 환경에서는 AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY 환경변수를 반드시 설정해야 합니다.

이러한 필수 환경변수 설정이 배포 문서나 환경 설정 가이드에 명확하게 문서화되어 있는지 확인이 필요합니다. 또한, 환경에 따라 다른 기본값을 사용하는 프로필 설정(예: application-prod.yml) 사용을 고려해보시기 바랍니다.

monew/src/main/java/com/spring/monew/common/filter/RequestIdFilter.java (1)

19-21: 상수 정의가 적절합니다.

헤더명, MDC 키, 프리픽스 모두 명확하고 일관성 있게 정의되었습니다.

monew/src/main/java/com/spring/monew/auth/config/SecurityConfig.java (2)

15-15: RequestIdFilter 의존성 주입이 올바르게 구성되었습니다.

생성자 주입 방식으로 필터를 적절히 주입하고 있습니다.

Also applies to: 23-23


45-46: 필터 체인 순서가 적절하게 구성되었습니다.

RequestIdFilterHeaderAuthFilter보다 먼저 실행되도록 올바르게 등록되어, 모든 후속 필터와 컨트롤러에서 MDC를 통해 Request ID에 접근할 수 있습니다. 이는 감사 로깅과 요청 추적에 필수적입니다.

monew/src/main/java/com/spring/monew/batch/scheduler/LogBackupScheduler.java (2)

23-37: 배치 작업 실행 로직이 적절합니다.

스케줄러가 JobParameters를 구성하고 배치 작업을 실행하는 방식이 ArticleBackupScheduler와 일관성 있게 구현되어 있습니다. 예외 처리도 스케줄 작업에 적합합니다.


22-22: PR 설명 내용 확인 필요

코드 검증 결과:

  • LogBackupScheduler.java 라인 22의 cron 표현식은 "0 0 3 * * *"로 확인됨 (매일 03:00 실행)
  • 코드베이스에서 "01:00" 관련 참조 없음

다만 PR 설명에서 실제로 "01:00"이라고 명시하는지 확인할 수 없으므로, 다음을 수동으로 확인해야 합니다:

  • PR 설명(PR description)에 기재된 실제 예정 시간
  • 의도한 스케줄이 01:00인지 03:00인지 확인

의도가 01:00이면 cron 수정이 필요하고, 03:00이 의도라면 PR 설명을 수정해야 합니다.

monew/src/main/java/com/spring/monew/backup/exception/BackupNotFoundException.java (1)

5-21: 예외 클래스 구현이 깔끔합니다.

백업 날짜 정보를 포함하는 예외 클래스 설계가 적절하며, 디버깅에 필요한 컨텍스트를 제공합니다.

monew/src/main/java/com/spring/monew/articleview/repository/ArticleViewRepository.java (2)

26-28: 하드 삭제 메서드가 적절히 구현되었습니다.

deleteByArticleId가 기사 하드 삭제 시 연관된 조회 기록을 정리하는 역할을 수행합니다. @Modifying(clearAutomatically = true) 사용으로 영속성 컨텍스트 동기화도 보장됩니다.


30-31: 카운트 조회 메서드 추가를 확인했습니다.

특정 기사의 조회 수를 집계하는 메서드가 추가되었습니다. 통계나 검증 용도로 활용될 수 있습니다.

monew/src/main/java/com/spring/monew/comment/repository/CommentRepository.java (2)

36-41: 기사별 댓글 삭제 및 카운트 메서드가 추가되었습니다.

하드 삭제 지원을 위한 deleteByArticleId와 통계 수집을 위한 countByArticleId가 적절히 구현되었습니다.


32-34: FK 제약 조건 확인 완료 - 삭제 순서가 올바르게 구현되었습니다.

ArticleServiceImpl.hardDeleteArticle() 메서드에서 다음과 같이 올바른 순서로 삭제가 수행됩니다:

  1. commentRepository.deleteCommentLikesByArticleId(articleId) - 댓글 좋아요 삭제
  2. commentRepository.deleteByArticleId(articleId) - 댓글 삭제
  3. articleViewRepository.deleteByArticleId(articleId) - 조회 기록 삭제
  4. articleRepository.hardDelete(articleId) - 기사 삭제

@Transactional(isolation = Isolation.SERIALIZABLE) 어노테이션으로 트랜잭션 안정성이 보장되며, FK 제약 조건으로 인한 오류는 발생하지 않습니다. 코드 변경 사항이 올바르게 구현되었습니다.

monew/src/main/java/com/spring/monew/backup/exception/S3ServiceException.java (1)

3-11: S3 예외 클래스가 적절히 구현되었습니다.

S3 작업 실패를 래핑하는 표준 예외 패턴이 올바르게 적용되었습니다. GlobalExceptionHandler와의 통합도 적절합니다.

monew/src/main/java/com/spring/monew/batch/controller/BatchJobController.java (1)

47-55: 로그 백업 트리거 엔드포인트가 추가되었습니다.

로그 백업 수동 실행 기능이 명확히 구현되었습니다.

기사 백업 엔드포인트와 동일하게, 관리자 권한 검증이 필요한지 확인하세요.

monew/src/main/java/com/spring/monew/batch/scheduler/ArticleBackupScheduler.java (1)

22-33: 배치 작업 스케줄링 구현이 올바릅니다.

일일 02:00에 실행되는 크론 표현식과 작업 파라미터 구성이 요구사항과 일치합니다. LogBackupScheduler와 일관된 패턴을 사용하고 있어 유지보수성이 좋습니다.

monew/src/main/java/com/spring/monew/backup/service/S3BackupService.java (1)

9-15: 인터페이스 설계가 명확하고 간결합니다.

백업 업로드, 다운로드, 존재 확인의 세 가지 핵심 기능을 잘 정의하고 있습니다.

monew/src/main/java/com/spring/monew/batch/config/ArticleBackupBatchConfig.java (1)

33-48: 배치 작업 구성이 올바릅니다.

Job과 Step 정의가 Spring Batch 모범 사례를 따르고 있으며, 청크 크기 100은 적절합니다.

monew/src/main/java/com/spring/monew/article/service/ArticleService.java (1)

4-4: 인터페이스 확장이 깔끔합니다.

소프트 삭제, 하드 삭제, 백업 복원 메서드가 명확하게 정의되어 있으며, 요구사항을 잘 반영하고 있습니다.

Also applies to: 29-33

monew/src/main/java/com/spring/monew/backup/service/LogBackupService.java (1)

5-11: 로그 백업 인터페이스가 명확합니다.

로그 업로드, 로컬 정리, 존재 확인의 세 가지 기능이 잘 정의되어 있습니다.

Comment on lines +138 to +155
@GetMapping("/restore")
@Operation(
summary = "백업에서 기사 복원",
description = "S3 백업에서 지정된 날짜 범위의 누락된 기사를 복원합니다. 최대 31일 범위까지 가능합니다."
)
public ResponseEntity<ArticleRestoreResultDto> restoreArticles(
@Parameter(description = "시작 날짜", required = true)
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,

@Parameter(description = "종료 날짜", required = true)
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to,

Principal principal
) {
UUID userId = userExtractor.extractUserId(principal);
ArticleRestoreResultDto result = articleService.restoreArticlesFromBackup(from, to, userId);
return ResponseEntity.ok(result);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

HTTP 메서드 선택 오류 – 상태 변경은 GET으로 노출하면 안 됩니다.

restoreArticles는 백업 데이터를 실제로 복원하여 시스템 상태를 변경하지만, 현재 @GetMapping으로 노출되어 있습니다. GET 요청은 캐시·프리페치·검색봇 등에 의해 의도치 않게 호출될 수 있고, HTTP 명세상 부작용이 없어야 하기 때문에 안전하지 않습니다. 복원처럼 상태를 바꾸는 작업은 POST(또는 PUT/PATCH) 등 안전하지 않은 메서드로 전환해 주세요.

-  @GetMapping("/restore")
+  @PostMapping("/restore")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@GetMapping("/restore")
@Operation(
summary = "백업에서 기사 복원",
description = "S3 백업에서 지정된 날짜 범위의 누락된 기사를 복원합니다. 최대 31일 범위까지 가능합니다."
)
public ResponseEntity<ArticleRestoreResultDto> restoreArticles(
@Parameter(description = "시작 날짜", required = true)
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
@Parameter(description = "종료 날짜", required = true)
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to,
Principal principal
) {
UUID userId = userExtractor.extractUserId(principal);
ArticleRestoreResultDto result = articleService.restoreArticlesFromBackup(from, to, userId);
return ResponseEntity.ok(result);
}
@PostMapping("/restore")
@Operation(
summary = "백업에서 기사 복원",
description = "S3 백업에서 지정된 날짜 범위의 누락된 기사를 복원합니다. 최대 31일 범위까지 가능합니다."
)
public ResponseEntity<ArticleRestoreResultDto> restoreArticles(
@Parameter(description = "시작 날짜", required = true)
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
@Parameter(description = "종료 날짜", required = true)
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to,
Principal principal
) {
UUID userId = userExtractor.extractUserId(principal);
ArticleRestoreResultDto result = articleService.restoreArticlesFromBackup(from, to, userId);
return ResponseEntity.ok(result);
}
🤖 Prompt for AI Agents
In
monew/src/main/java/com/spring/monew/article/controller/ArticleController.java
around lines 138 to 155, the restoreArticles endpoint is currently exposed with
@GetMapping which is inappropriate for a state-changing operation; change the
mapping to a non-safe HTTP verb (e.g., @PostMapping("/restore")) and update any
relevant method-level annotations/docs if needed (OpenAPI summary/description
can remain), ensure any callers/tests that invoke this endpoint use POST, and if
your API expects complex input in the body consider switching request parameters
to a request body DTO; keep authentication/parameter extraction (userId)
unchanged.

Comment on lines +37 to +45
@Operation(
summary = "기사 백업 배치 작업 수동 트리거",
description = "articleBackupJob을 수동으로 실행하여 모든 기사를 S3에 백업합니다."
)
@PostMapping("/trigger/article-backup")
public ResponseEntity<BatchJobTriggerResponse> triggerArticleBackupJob() {
BatchJobTriggerResponse response = batchJobService.triggerArticleBackupJob();
return ResponseEntity.ok(response);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

기사 백업 트리거 엔드포인트가 추가되었습니다.

수동 백업 트리거 기능이 기존 패턴과 일관되게 구현되었으며, Swagger 문서화도 적절합니다.

배치 작업 트리거는 민감한 작업이므로, 관리자 권한이 필요한지 확인하세요. 전역 보안 설정이나 메서드 레벨 보안이 적용되어 있는지 검증하세요:


🏁 Script executed:

#!/bin/bash
# 보안 설정 확인 - SecurityConfig나 전역 필터 검색
rg -n "@PreAuthorize|@Secured|hasRole|permitAll" --type java -g "*Security*" -A 3

# 배치 컨트롤러 경로에 대한 보안 설정 확인
rg -n "/api/batch" --type java -g "*Security*" -C 3

Length of output: 2000


배치 작업 엔드포인트의 보안 설정이 미흡합니다 - 즉시 수정 필요

확인 결과, SecurityConfig.java 라인 42에서 /api/batch/** 경로가 .permitAll()로 설정되어 있습니다. 이는 인증 없이 누구나 기사 백업 배치 작업을 트리거할 수 있다는 의미입니다.

모든 기사를 S3에 백업하는 민감한 작업이므로 관리자 권한만 실행 가능하도록 수정이 필수입니다:

  • SecurityConfig.java 라인 42: /api/batch/**hasRole('ADMIN') 또는 유사한 권한 검증으로 변경
  • 또는 @PreAuthorize 애노테이션을 BatchJobController의 메서드에 추가하여 메서드 레벨 보안 적용
🤖 Prompt for AI Agents
In monew/src/main/java/com/spring/monew/batch/controller/BatchJobController.java
lines 37-45 the triggerArticleBackupJob endpoint is publicly exposed; update
security so only admins can call it by either: (A) change
monew/src/main/java/com/spring/monew/config/SecurityConfig.java at/around line
42 to replace .permitAll() for "/api/batch/**" with an authority check such as
.hasRole("ADMIN") (ensure role prefix usage matches your config), or (B) add
method-level security: annotate triggerArticleBackupJob with
@PreAuthorize("hasRole('ADMIN')") and enable pre/post annotations in your
security config (e.g., @EnableGlobalMethodSecurity(prePostEnabled = true));
ensure necessary imports and that roles used match existing user authorities.

Comment on lines +17 to +21
@Value("${aws.s3.access-key}")
private String accessKey;

@Value("${aws.s3.secret-key}")
private String secretKey;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

프로덕션 환경에서 credentials를 평문으로 저장하는 것은 보안 위험입니다.

평문 프로퍼티로 AWS credentials를 주입하면 소스 코드, 설정 파일, 환경 변수 등에 노출될 위험이 있습니다. 프로덕션 환경에서는 다음 방안을 권장합니다:

  • IAM Role 기반 인증(EC2, ECS, EKS 등에서 instance profile 사용)
  • AWS Secrets Manager 또는 Systems Manager Parameter Store를 통한 암호화된 credentials 관리
  • Spring Cloud Config의 암호화 기능 활용

또한 lines 31-34에서 StaticCredentialsProvider를 사용하는 것은 개발 환경에는 적합하지만, 프로덕션에서는 DefaultCredentialsProvider 체인을 사용하여 AWS SDK가 자동으로 credential source를 탐지하도록 하는 것이 best practice입니다.

프로덕션 배포 전에 다음과 같이 수정하는 것을 고려하세요:

+  // 프로덕션: IAM Role 또는 DefaultCredentialsProvider 사용 권장
   @Bean
   public S3Client s3Client() {
-    if (accessKey == null || accessKey.isEmpty() || secretKey == null || secretKey.isEmpty()) {
-      return S3Client.builder()
-          .region(Region.of(region))
-          .build();
-    }
-
-    AwsBasicCredentials awsCredentials = AwsBasicCredentials.create(accessKey, secretKey);
-    return S3Client.builder()
-        .region(Region.of(region))
-        .credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
-        .build();
+    // 프로덕션: credentials 제거하고 IAM Role 활용
+    return S3Client.builder()
+        .region(Region.of(region))
+        .build(); // DefaultCredentialsProvider 체인 자동 사용
   }

개발 환경에서만 명시적 credentials가 필요한 경우, profile 또는 환경별 분기 처리를 추가하세요.

Committable suggestion skipped: line range outside the PR's diff.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
monew/src/main/java/com/spring/monew/article/controller/ArticleController.java (1)

152-169: 상태 변경 작업은 GET 메서드로 노출하면 안 됩니다. (과거 리뷰 지적사항 미반영)

이 엔드포인트는 과거 리뷰에서 이미 지적된 문제가 해결되지 않았습니다. restoreArticles는 S3 백업에서 기사를 복원하여 시스템 상태를 변경하는 작업이지만 @GetMapping으로 노출되어 있습니다. GET 요청은 HTTP 명세상 안전한(safe) 메서드로, 부작용이 없어야 하며 캐시·프리페치·검색봇 등에 의해 의도치 않게 호출될 수 있습니다. 복원처럼 상태를 변경하는 작업은 반드시 POST 메서드로 변경해야 합니다.

다음 diff를 적용하세요:

-  @GetMapping("/restore")
+  @PostMapping("/restore")
   @Operation(
       summary = "백업에서 기사 복원",
       description = "S3 백업에서 지정된 날짜 범위의 누락된 기사를 복원합니다. 최대 31일 범위까지 가능합니다."
   )
+  @ApiResponses({
+      @ApiResponse(responseCode = "200", description = "복원 성공"),
+      @ApiResponse(responseCode = "400", description = "잘못된 날짜 범위"),
+      @ApiResponse(responseCode = "500", description = "서버 내부 오류")
+  })
   public ResponseEntity<ArticleRestoreResultDto> restoreArticles(
🧹 Nitpick comments (3)
monew/src/main/java/com/spring/monew/article/controller/ArticleController.java (3)

128-138: ApiResponses 문서화 추가를 권장합니다.

소프트 삭제 엔드포인트의 REST 설계는 적절하나(DELETE 메서드, 204 응답), 다른 엔드포인트와의 일관성을 위해 OpenAPI 문서화를 추가하는 것이 좋습니다.

다음 diff를 적용하여 문서화를 추가하세요:

   @DeleteMapping("/{articleId}")
   @ResponseStatus(HttpStatus.NO_CONTENT)
   @Operation(summary = "뉴스 기사 삭제", description = "특정 기사를 소프트 삭제합니다 (논리 삭제)")
+  @ApiResponses({
+      @ApiResponse(responseCode = "204", description = "삭제 성공"),
+      @ApiResponse(responseCode = "404", description = "기사를 찾을 수 없음"),
+      @ApiResponse(responseCode = "500", description = "서버 내부 오류")
+  })
   public void softDeleteArticle(

140-150: ApiResponses 문서화 추가를 권장합니다.

하드 삭제 엔드포인트의 REST 설계는 적절하나, 다른 엔드포인트와의 일관성을 위해 OpenAPI 문서화를 추가하는 것이 좋습니다.

다음 diff를 적용하여 문서화를 추가하세요:

   @DeleteMapping("/{articleId}/hard")
   @ResponseStatus(HttpStatus.NO_CONTENT)
   @Operation(summary = "뉴스 기사 영구 삭제", description = "특정 기사 및 관련된 모든 데이터(댓글, 좋아요, 조회수)를 데이터베이스에서 완전히 삭제합니다")
+  @ApiResponses({
+      @ApiResponse(responseCode = "204", description = "삭제 성공"),
+      @ApiResponse(responseCode = "404", description = "기사를 찾을 수 없음"),
+      @ApiResponse(responseCode = "500", description = "서버 내부 오류")
+  })
   public void hardDeleteArticle(

152-169: 날짜 범위 검증 추가를 권장합니다.

설명에 "최대 31일 범위까지 가능합니다"라고 명시되어 있으나, 컨트롤러 레벨에서 날짜 범위에 대한 검증이 없습니다. 잘못된 요청을 조기에 차단하기 위해 from < to 검증 및 최대 31일 범위 제한을 추가하는 것이 좋습니다.

컨트롤러 메서드 시작 부분에 검증 로직을 추가하거나, 서비스 레이어에서 검증을 수행하고 적절한 예외를 발생시키도록 구현하세요:

public ResponseEntity<ArticleRestoreResultDto> restoreArticles(
    @Parameter(description = "시작 날짜", required = true)
    @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
    
    @Parameter(description = "종료 날짜", required = true)
    @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to,
    
    Principal principal
) {
  // 날짜 범위 검증
  if (from.isAfter(to)) {
    throw new IllegalArgumentException("시작 날짜는 종료 날짜보다 이전이어야 합니다");
  }
  
  long daysDiff = Duration.between(from, to).toDays();
  if (daysDiff > 31) {
    throw new IllegalArgumentException("날짜 범위는 최대 31일까지 가능합니다");
  }
  
  UUID userId = userExtractor.extractUserId(principal);
  ArticleRestoreResultDto result = articleService.restoreArticlesFromBackup(from, to, userId);
  return ResponseEntity.ok(result);
}

또는 서비스 레이어에서 검증을 수행하는 방식을 선택할 수 있습니다.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0632211 and 54a03c0.

📒 Files selected for processing (1)
  • monew/src/main/java/com/spring/monew/article/controller/ArticleController.java (5 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
monew/src/main/java/com/spring/monew/article/controller/ArticleController.java (1)
monew/src/main/java/com/spring/monew/auth/config/SecurityConfig.java (1)
  • RequiredArgsConstructor (17-60)
🔇 Additional comments (2)
monew/src/main/java/com/spring/monew/article/controller/ArticleController.java (2)

1-34: 임포트 및 컨트롤러 태그 업데이트 적절합니다.

새로운 삭제/복원 기능에 필요한 임포트와 API 태그 설명이 정확하게 추가되었습니다. 특히 태그를 "뉴스 기사 관리"로 변경한 것은 확장된 기능 범위를 잘 반영하고 있습니다.


40-126: OpenAPI 문서화 개선이 좋습니다.

기존 엔드포인트에 적절한 응답 코드와 설명이 추가되어 API 문서 품질이 향상되었습니다.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (3)
monew/src/main/java/com/spring/monew/batch/controller/BatchJobController.java (2)

43-51: 이전 리뷰에서 지적된 보안 문제가 해결되지 않았습니다

이전 리뷰에서 지적된 바와 같이, /api/batch/** 경로가 SecurityConfig에서 .permitAll()로 설정되어 있어 인증 없이 누구나 이 엔드포인트를 호출할 수 있습니다. 모든 기사를 S3에 백업하는 민감한 작업이므로 관리자 권한으로 제한해야 합니다.

추가 개선 사항:

일관성을 위해 @ApiResponses 어노테이션을 추가하는 것을 고려하세요 (37-41라인의 triggerNewsCollectionJob과 동일한 패턴):

 @Operation(
         summary = "기사 백업 배치 작업 수동 트리거",
         description = "articleBackupJob을 수동으로 실행하여 모든 기사를 S3에 백업합니다."
 )
+@ApiResponses({
+    @ApiResponse(responseCode = "200", description = "배치 작업 성공"),
+    @ApiResponse(responseCode = "500", description = "서버 내부 오류")
+})
 @PostMapping("/trigger/article-backup")
 public ResponseEntity<BatchJobTriggerResponse> triggerArticleBackupJob() {

53-61: 동일한 보안 문제가 적용됩니다

이 엔드포인트도 43-51라인의 기사 백업 엔드포인트와 동일한 보안 문제가 있습니다. 로그 파일을 S3에 백업하는 작업은 민감한 정보(애플리케이션 로그, 클라이언트 IP 등)를 다루므로 관리자 권한이 필요합니다.

추가 권장 사항:

  1. 일관성을 위해 @ApiResponses 어노테이션 추가
  2. 반복적인 배치 작업 트리거를 방지하기 위한 rate limiting 고려 (예: 동일 작업을 5분 내 재실행 방지)
 @Operation(
         summary = "로그 백업 배치 작업 수동 트리거",
         description = "logBackupJob을 수동으로 실행하여 로그 파일을 S3에 백업합니다."
 )
+@ApiResponses({
+    @ApiResponse(responseCode = "200", description = "배치 작업 성공"),
+    @ApiResponse(responseCode = "500", description = "서버 내부 오류")
+})
 @PostMapping("/trigger/log-backup")
 public ResponseEntity<BatchJobTriggerResponse> triggerLogBackupJob() {
monew/src/main/java/com/spring/monew/article/controller/ArticleController.java (1)

155-172: [중요] GET 메서드 사용 오류 – 이전 리뷰에서 지적된 문제가 수정되지 않았습니다.

복원 작업은 시스템 상태를 변경하는 작업이므로 GET 메서드를 사용하면 안 됩니다. GET 요청은 다음과 같은 위험이 있습니다:

  • 브라우저 프리페치나 검색 엔진 크롤러에 의해 의도치 않게 실행될 수 있음
  • 브라우저 캐시로 인한 예상치 못한 동작
  • 북마크나 링크 공유로 인한 우발적 실행

이전 리뷰에서도 동일한 문제가 지적되었으나 수정되지 않았습니다. 반드시 POST 메서드로 변경해야 합니다.

-  @GetMapping("/restore")
+  @PostMapping("/restore")
   @Operation(
       summary = "백업에서 기사 복원",
       description = "S3 백업에서 지정된 날짜 범위의 누락된 기사를 복원합니다. 최대 31일 범위까지 가능합니다."
   )
+  @ApiResponses({
+      @ApiResponse(responseCode = "200", description = "복원 성공"),
+      @ApiResponse(responseCode = "400", description = "잘못된 날짜 범위"),
+      @ApiResponse(responseCode = "500", description = "서버 내부 오류")
+  })
   public ResponseEntity<ArticleRestoreResultDto> restoreArticles(

추가로, 날짜 범위 검증(from < to, 범위 <= 31일)이 서비스 레이어에 구현되어 있는지 확인하시기 바랍니다.

🧹 Nitpick comments (2)
monew/src/main/java/com/spring/monew/article/controller/ArticleController.java (2)

131-141: 소프트 삭제 엔드포인트 구현이 적절합니다.

DELETE 메서드 사용과 204 응답 코드가 올바르게 설정되었습니다. 다만 일관성을 위해 다른 엔드포인트들처럼 @ApiResponses 애노테이션을 추가하는 것을 권장합니다.

  @DeleteMapping("/{articleId}")
  @ResponseStatus(HttpStatus.NO_CONTENT)
  @Operation(summary = "뉴스 기사 삭제", description = "특정 기사를 소프트 삭제합니다 (논리 삭제)")
+  @ApiResponses({
+      @ApiResponse(responseCode = "204", description = "삭제 성공"),
+      @ApiResponse(responseCode = "404", description = "기사를 찾을 수 없음"),
+      @ApiResponse(responseCode = "500", description = "서버 내부 오류")
+  })
  public void softDeleteArticle(

143-153: 하드 삭제 엔드포인트 구현이 적절합니다.

영구 삭제 동작에 대한 명확한 설명과 올바른 HTTP 메서드를 사용하고 있습니다. 다만 일관성을 위해 @ApiResponses 애노테이션을 추가하는 것을 권장합니다.

  @DeleteMapping("/{articleId}/hard")
  @ResponseStatus(HttpStatus.NO_CONTENT)
  @Operation(summary = "뉴스 기사 영구 삭제", description = "특정 기사 및 관련된 모든 데이터(댓글, 좋아요, 조회수)를 데이터베이스에서 완전히 삭제합니다")
+  @ApiResponses({
+      @ApiResponse(responseCode = "204", description = "삭제 성공"),
+      @ApiResponse(responseCode = "404", description = "기사를 찾을 수 없음"),
+      @ApiResponse(responseCode = "500", description = "서버 내부 오류")
+  })
  public void hardDeleteArticle(
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 54a03c0 and a693f5f.

📒 Files selected for processing (2)
  • monew/src/main/java/com/spring/monew/article/controller/ArticleController.java (5 hunks)
  • monew/src/main/java/com/spring/monew/batch/controller/BatchJobController.java (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build-and-test
🔇 Additional comments (1)
monew/src/main/java/com/spring/monew/article/controller/ArticleController.java (1)

4-29: 새로운 엔드포인트를 위한 import 추가가 적절합니다.

복원 및 삭제 기능을 위한 필요한 클래스들이 올바르게 import되었습니다.

@codeit-monew codeit-monew deleted a comment from coderabbitai bot Nov 4, 2025
CommentRepository 패턴과 일관성을 위해 is_deleted = true 조건 추가
업로드 성공 후 로컬 파일 삭제를 별도 try-catch로 분리하여 정확한 실패 지점 보고
UTC 자정 대신 Asia/Seoul 타임존 기준으로 날짜 범위 계산하여 백업 누락 방지
메서드 레벨 트랜잭션 제거하고 ArticleService로 위임하여 개별 트랜잭션 처리
장시간 트랜잭션으로 인한 잠금/타임아웃 문제 해결
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
monew/src/main/java/com/spring/monew/batch/config/ArticleBackupBatchConfig.java (1)

59-64: 타임존 처리는 개선되었으나, 설정 가능하도록 변경을 권장합니다.

ZoneId.of("Asia/Seoul")을 사용하여 이전 리뷰의 UTC 타임존 문제를 해결했습니다. 하지만 타임존이 하드코딩되어 있어 배포 환경이나 요구사항 변경 시 유연성이 떨어집니다.

다음과 같이 설정 가능하도록 변경하는 것을 권장합니다:

+  @Value("${backup.timezone:Asia/Seoul}")
+  private String backupTimezone;
+
   @Bean
   @StepScope
   public JpaPagingItemReader<Article> articleBackupReader() {
-    ZoneId zoneId = ZoneId.of("Asia/Seoul");
+    ZoneId zoneId = ZoneId.of(backupTimezone);
     LocalDate yesterday = LocalDate.now(zoneId).minusDays(1);
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a693f5f and 3087bd2.

📒 Files selected for processing (4)
  • monew/src/main/java/com/spring/monew/article/repository/ArticleRepository.java (1 hunks)
  • monew/src/main/java/com/spring/monew/backup/service/impl/S3LogBackupServiceImpl.java (1 hunks)
  • monew/src/main/java/com/spring/monew/batch/config/ArticleBackupBatchConfig.java (1 hunks)
  • monew/src/main/java/com/spring/monew/batch/scheduler/ArticleCleanupScheduler.java (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • monew/src/main/java/com/spring/monew/backup/service/impl/S3LogBackupServiceImpl.java
  • monew/src/main/java/com/spring/monew/article/repository/ArticleRepository.java
🧰 Additional context used
🧬 Code graph analysis (2)
monew/src/main/java/com/spring/monew/batch/scheduler/ArticleCleanupScheduler.java (1)
monew/src/main/java/com/spring/monew/auth/config/SecurityConfig.java (1)
  • RequiredArgsConstructor (17-60)
monew/src/main/java/com/spring/monew/batch/config/ArticleBackupBatchConfig.java (1)
monew/src/main/java/com/spring/monew/auth/config/SecurityConfig.java (1)
  • RequiredArgsConstructor (17-60)
🔇 Additional comments (4)
monew/src/main/java/com/spring/monew/batch/scheduler/ArticleCleanupScheduler.java (2)

23-24: 이전 트랜잭션 문제가 해결되었습니다.

과거 리뷰에서 제기된 메서드 레벨 @Transactional 어노테이션이 제거되어, 장시간 트랜잭션으로 인한 성능 및 안정성 문제가 해결되었습니다. 현재는 서비스 계층에 삭제 로직을 위임하는 권장 패턴을 따르고 있습니다.


23-23: 스케줄 실행 시간이 다른 정리 작업들과 불일치합니다.

ArticleCleanupScheduler가 15:00(오후 3시)에 실행되도록 설정되어 있는데, 프로젝트의 다른 정리 작업들은 모두 야간 시간대(00:00~03:00)에 실행됩니다:

  • CommentCleanupScheduler: 00:00
  • UserCleanupScheduler: 00:00
  • NotificationCleanupScheduler: 03:00

이것이 의도된 설정인지 확인해 주시기 바랍니다. 필요시 다른 정리 작업들과 일관성 있게 조정을 검토해 주세요.

monew/src/main/java/com/spring/monew/batch/config/ArticleBackupBatchConfig.java (2)

38-54: 배치 작업 구조가 올바르게 구성되어 있습니다.

Job과 Step 구성이 적절하며, chunk 크기(100)와 reader의 page 크기가 일치합니다. Writer를 listener로도 등록한 것은 lifecycle 훅을 위한 적절한 패턴입니다.


73-73: 파일 이름 패턴을 잘못 파악했습니다. 검토 코멘트는 부정확합니다.

실제 코드를 확인한 결과, S3BackupServiceImpl.java의 generateS3Key() 메서드는 파일명을 articles/YYYY-MM-DD.json.gz 형식으로 생성합니다. 리뷰 코멘트에서 가정한 deleted-articles-YYYY-MM-DD.json 패턴이 아닙니다.

코드는 일관성 있게 동작합니다:

  • 파일명 패턴(articles/YYYY-MM-DD.json.gz) → 일반 기사 백업
  • 쿼리(isDeleted = false) → 삭제되지 않은 기사만 선택
  • 날짜 필터(createdAt) → 어제 생성된 기사 대상

이 배치는 어제 생성된 활성(삭제되지 않은) 기사를 백업하려는 명확한 의도를 가지고 있으며, 현재 구현이 그 의도를 올바르게 반영하고 있습니다.

Likely an incorrect or invalid review comment.

배치 작업에서 시스템 사용자 ID(00000000-0000-0000-0000-000000000000) 사용
MDC에 배치 requestId 설정하여 감사 로그 추적성 향상
monew-application-2025-11-03.log → monew-application.2025-11-03.log
logback-spring.xml의 fileNamePattern과 동일한 형식 사용
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
monew/src/main/java/com/spring/monew/backup/service/impl/S3LogBackupServiceImpl.java (1)

127-129: 로그 파일 네이밍 규칙 불일치로 인해 기능이 작동하지 않습니다.

Line 128의 generateLogFilePathlogFileName + "." + logDate로 경로를 생성하여 monew-application.2025-11-02.log 형태의 파일명을 만들지만, extractDateFromFileName (lines 145-150)은 마지막 하이픈(-) 이후 문자열로 날짜를 파싱합니다. PR 설명과 Logback 롤링 패턴은 monew-application-YYYY-MM-DD.log 형태(하이픈 구분)를 명시하고 있습니다.

이 불일치로 인해:

  1. uploadLogFile이 실제 로그 파일을 찾지 못함 (항상 line 51-54에서 조기 리턴)
  2. cleanupLocalLogs의 날짜 파싱 실패로 오래된 로그 삭제 불가
  3. logFileExists가 항상 false 반환

이전 리뷰에서 동일한 이슈가 지적되고 해결된 것으로 표시되었으나, 코드에는 여전히 점(.) 연결 방식이 남아있습니다.

다음 diff를 적용하여 하이픈 기반 패턴으로 수정하세요:

   private String generateLogFilePath(LocalDate logDate) {
-    return Paths.get(logFilePath, logFileName + "." + logDate.format(DATE_FORMATTER) + ".log").toString();
+    return Paths.get(logFilePath, logFileName + "-" + logDate.format(DATE_FORMATTER) + ".log").toString();
   }
🧹 Nitpick comments (4)
monew/src/main/java/com/spring/monew/batch/scheduler/ArticleCleanupScheduler.java (4)

21-21: 시스템 사용자 ID에 대한 문서화를 추가하는 것이 좋습니다.

all-zeros UUID를 시스템 사용자 ID로 사용하는 것은 합리적이지만, 이것이 실제 데이터베이스의 사용자 테이블에 존재하는 특수 사용자인지, 아니면 단순히 감사 로그의 마커로만 사용되는지 명확하지 않습니다. Javadoc 주석을 추가하여 이 UUID의 의미와 사용 목적을 문서화하면 유지보수성이 향상됩니다.

예시:

+  /**
+   * 시스템에 의해 실행되는 배치 작업의 사용자 ID.
+   * 감사 로그에서 사용자 작업과 배치 작업을 구분하기 위해 사용됩니다.
+   */
   private static final UUID SYSTEM_USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000000");

27-27: 클린업 작업 실행 시간을 오프피크 시간대로 조정하는 것을 권장합니다.

현재 스케줄러는 매일 15:00 (오후 3시)에 실행되도록 설정되어 있습니다. 대용량 삭제 작업은 데이터베이스 리소스를 많이 사용하므로, 사용자 트래픽이 적은 새벽 시간대(예: 03:00-04:00)에 실행하는 것이 시스템 성능과 사용자 경험에 더 유리합니다.

예시:

-  @Scheduled(cron = "0 0 15 * * *")
+  @Scheduled(cron = "0 0 3 * * *")  // 새벽 3시 실행
   public void deleteSoftDeletedArticles() {

36-36: 클린업 임계값을 설정 가능하게 외부화하는 것을 권장합니다.

30일 임계값이 하드코딩되어 있어 운영 환경에서 유연하게 조정할 수 없습니다. 비즈니스 요구사항이나 스토리지 정책 변경 시 코드 수정과 재배포가 필요합니다.

application.yml에 설정 추가:

batch:
  cleanup:
    retention-days: 30

코드 수정:

+  @Value("${batch.cleanup.retention-days:30}")
+  private int retentionDays;
+
   @Scheduled(cron = "0 0 15 * * *")
   public void deleteSoftDeletedArticles() {
     // ...
-    Instant threshold = Instant.now().minus(30, ChronoUnit.DAYS);
+    Instant threshold = Instant.now().minus(retentionDays, ChronoUnit.DAYS);

49-57: 대량 기사 처리 시 안전장치 추가를 고려하세요.

현재 구현은 조회된 모든 기사를 순차적으로 처리합니다. 일반적으로는 문제가 없지만, 예외적으로 수천 개의 기사가 대상이 될 경우 배치 실행 시간이 매우 길어지고 중간에 실패 시 재처리가 어려워질 수 있습니다.

최대 배치 크기 제한을 추가하는 것을 고려하세요:

+  @Value("${batch.cleanup.max-batch-size:1000}")
+  private int maxBatchSize;
+
   // ...
   List<UUID> articleIds = articleRepository.findSoftDeletedBefore(threshold);
   
   if (articleIds.isEmpty()) {
     log.info("[Batch] 정리할 삭제된 게시글이 없습니다.");
     return;
   }
   
+  if (articleIds.size() > maxBatchSize) {
+    log.warn("[Batch] 대상 게시글 {}개가 최대 배치 크기 {}를 초과하여 일부만 처리합니다.", 
+        articleIds.size(), maxBatchSize);
+    articleIds = articleIds.subList(0, maxBatchSize);
+  }
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3087bd2 and 7e357cc.

📒 Files selected for processing (2)
  • monew/src/main/java/com/spring/monew/backup/service/impl/S3LogBackupServiceImpl.java (1 hunks)
  • monew/src/main/java/com/spring/monew/batch/scheduler/ArticleCleanupScheduler.java (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
monew/src/main/java/com/spring/monew/batch/scheduler/ArticleCleanupScheduler.java (1)
monew/src/main/java/com/spring/monew/auth/config/SecurityConfig.java (1)
  • RequiredArgsConstructor (17-60)
monew/src/main/java/com/spring/monew/backup/service/impl/S3LogBackupServiceImpl.java (2)
monew/src/main/java/com/spring/monew/backup/exception/S3ServiceException.java (1)
  • S3ServiceException (3-11)
monew/src/main/java/com/spring/monew/backup/service/impl/S3BackupServiceImpl.java (1)
  • Slf4j (24-152)
🔇 Additional comments (6)
monew/src/main/java/com/spring/monew/batch/scheduler/ArticleCleanupScheduler.java (2)

29-34: 배치 컨텍스트 초기화가 잘 구현되었습니다.

배치 요청 ID를 생성하고 MDC에 설정하여 로그 추적성을 보장하는 구현이 적절합니다. 시작 시간 기록과 로깅도 명확합니다.


59-64: 배치 완료 처리 및 리소스 정리가 올바르게 구현되었습니다.

실행 시간 측정, 성공/실패 통계 로깅, 그리고 finally 블록에서의 MDC 정리가 모두 적절하게 구현되었습니다. 예외 발생 시에도 MDC가 확실히 정리되어 메모리 누수를 방지합니다.

monew/src/main/java/com/spring/monew/backup/service/impl/S3LogBackupServiceImpl.java (4)

82-87: 로컬 파일 삭제 오류 처리가 올바르게 분리되었습니다.

S3 업로드 성공 후 로컬 파일 삭제를 별도의 try-catch 블록으로 분리하여, 삭제 실패 시에도 예외를 던지지 않고 로그만 남기도록 구현되었습니다. 업로드 성공과 삭제 실패를 명확히 구분하여 운영 혼란을 방지합니다.


131-133: LGTM!

S3 키 생성 로직이 올바르게 구현되었습니다. logs/YYYY-MM-DD.log.gz 형태로 일관된 키를 생성합니다.


135-141: LGTM!

GZIP 압축 로직이 표준 패턴을 따르며, try-with-resources로 리소스 관리가 적절하게 구현되었습니다. 초기 용량 설정을 통한 최적화도 적용되어 있습니다.


143-155: 날짜 파싱 로직은 올바르게 구현되었으나 generateLogFilePath 수정에 의존합니다.

extractDateFromFileName은 하이픈 기반 파일명에서 날짜를 추출하는 로직이 올바르게 구현되어 있습니다:

  • 마지막 하이픈 위치를 찾아 날짜 부분을 추출
  • 적절한 경계 검사로 IndexOutOfBoundsException 방지
  • 파싱 실패 시 null 반환

다만, generateLogFilePath (line 127-129)가 점(.) 기반 파일명을 생성하므로, 해당 메서드가 수정되어야 이 파싱 로직이 정상 작동합니다.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
monew/src/main/resources/application.yml (1)

16-27: 운영 환경에서 위험한 설정 - 즉시 수정 필요

현재 설정은 매우 위험합니다:

  • ddl-auto: none + init.mode: always: 애플리케이션 시작 시마다 schema.sql이 실행됩니다.
  • schema.sqlDROP TABLE 명령을 포함하고 있어 모든 데이터가 삭제됩니다.

권장 조치:

  1. 프로파일별 설정 분리:

    # application-local.yml
    spring:
      jpa:
        hibernate:
          ddl-auto: create-drop
      sql:
        init:
          mode: always
    
    # application-prod.yml
    spring:
      jpa:
        hibernate:
          ddl-auto: validate
      sql:
        init:
          mode: never
  2. 또는 현재 파일의 주석대로 설정:

    ddl-auto: validate  # not 'none'
    mode: never         # not 'always'

다음 명령으로 schema.sql의 DROP 명령을 확인하세요:

#!/bin/bash
# schema.sql의 DROP TABLE 명령 확인
echo "=== DROP TABLE commands in schema.sql ==="
rg -n "DROP TABLE" monew/src/main/resources/schema.sql
monew/src/main/resources/schema.sql (1)

16-23: DROP TABLE 명령과 application.yml 설정 조합이 위험함

이 스키마 파일은 모든 테이블을 삭제하는 DROP TABLE 명령을 포함하고 있습니다. application.ymlspring.sql.init.mode: always 설정과 결합되면 애플리케이션 시작 시마다 모든 데이터가 삭제됩니다.

이는 application.yml 파일에서 이미 지적한 critical issue와 연관된 문제입니다. 해당 설정을 즉시 수정하세요.

🧹 Nitpick comments (1)
monew/src/main/java/com/spring/monew/interest/service/impl/InterestServiceImpl.java (1)

92-98: 배치 소프트 삭제로 N+1 쿼리 문제 개선 필요

findAllByInterestIddeleteAll 조합은 Spring Data JPA의 기본 반복 삭제 패턴으로, 대량 데이터 시 성능 문제를 야기합니다. 현재 구현은 1번의 SELECT + N번의 UPDATE 쿼리를 발생시킵니다(예: 10,000건 = 10,001회 쿼리).

권장 개선사항:

ArticleRepository에 배치 소프트 삭제 메서드 추가:

@Modifying(clearAutomatically = true)
@Query("UPDATE Article a SET a.isDeleted = true, a.deletedAt = CURRENT_TIMESTAMP WHERE a.interest.id = :interestId")
void softDeleteByInterestId(@Param("interestId") UUID interestId);

InterestServiceImpl의 92-98줄을 다음으로 교체:

int deletedCount = articleRepository.softDeleteByInterestId(interestId);
if (deletedCount > 0) {
  log.info("관심사 삭제로 인한 연관 게시글 소프트 삭제: interestId={}, articleCount={}", 
      interestId, deletedCount);
}

이 방식은 단일 UPDATE 쿼리로 트랜잭션 시간을 단축하고 메모리 효율을 개선합니다.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7e357cc and beec253.

📒 Files selected for processing (8)
  • monew/src/main/java/com/spring/monew/article/repository/ArticleRepository.java (1 hunks)
  • monew/src/main/java/com/spring/monew/interest/domain/Interest.java (2 hunks)
  • monew/src/main/java/com/spring/monew/interest/service/impl/InterestServiceImpl.java (3 hunks)
  • monew/src/main/resources/application.yml (5 hunks)
  • monew/src/main/resources/schema-h2.sql (1 hunks)
  • monew/src/main/resources/schema.sql (3 hunks)
  • monew/src/test/java/com/spring/monew/interest/domain/InterestServiceTest.java (3 hunks)
  • monew/src/test/java/com/spring/monew/interest/service/InterestIntegrationTest.java (4 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • monew/src/main/java/com/spring/monew/article/repository/ArticleRepository.java
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-10-29T09:36:40.959Z
Learnt from: Namsoo315
Repo: codeit-monew/sb05-monew-team7 PR: 57
File: monew/src/test/java/com/spring/monew/comment/repository/CommentRepositoryTest.java:175-197
Timestamp: 2025-10-29T09:36:40.959Z
Learning: In the file monew/src/test/java/com/spring/monew/comment/repository/CommentRepositoryTest.java, the content strings like "like0", "like5", "like3" in the sortByLikeCount test are just comment content labels, not indicators of the actual like counts. The actual like counts are controlled by incrementLikeCount() calls.

Applied to files:

  • monew/src/test/java/com/spring/monew/interest/domain/InterestServiceTest.java
  • monew/src/test/java/com/spring/monew/interest/service/InterestIntegrationTest.java
🧬 Code graph analysis (2)
monew/src/main/java/com/spring/monew/interest/service/impl/InterestServiceImpl.java (2)
monew/src/main/java/com/spring/monew/subscription/service/impl/SubscriptionServiceImpl.java (1)
  • Service (16-57)
monew/src/main/java/com/spring/monew/comment/service/impl/CommentServiceImpl.java (1)
  • Service (21-89)
monew/src/main/java/com/spring/monew/interest/domain/Interest.java (3)
monew/src/main/java/com/spring/monew/article/domain/Article.java (1)
  • Entity (15-143)
monew/src/main/java/com/spring/monew/comment/domain/Comment.java (1)
  • Entity (27-105)
monew/src/main/java/com/spring/monew/user/domain/User.java (1)
  • Entity (20-88)
🔇 Additional comments (14)
monew/src/test/java/com/spring/monew/interest/domain/InterestServiceTest.java (4)

12-14: LGTM!

관심사 삭제 시 연관 게시글 처리를 테스트하기 위해 필요한 의존성들이 적절히 추가되었습니다.


41-42: LGTM!

ArticleRepository 목 객체가 적절히 추가되어 관심사 삭제 시 연관 게시글 처리를 테스트할 수 있습니다.


141-151: LGTM!

관련 게시글이 없는 경우의 관심사 삭제 시나리오가 명확하게 테스트되고 있습니다. 적절한 검증이 수행되고 있습니다.


154-171: 검토 의견은 부정확함 - 코드는 올바르게 구현됨

검토 의견이 잘못된 가정에 기반하고 있습니다. @SQLDelete 어노테이션이 Article 엔티티에 설정되어 있으면, Spring Data JPA의 deleteAll() 메서드는 자동으로 이 커스텀 SQL을 존중하여 소프트 삭제를 수행합니다.

검증 결과:

  • Article 엔티티 (line 19): @SQLDelete(sql = "UPDATE articles SET is_deleted = true, deleted_at = CURRENT_TIMESTAMP WHERE id = ?") 설정됨
  • ArticleRepository: deleteAll() 오버라이드 없음 → Spring Data의 기본 동작이 @SQLDelete 적용
  • InterestServiceImpl.removeInterest() (line 97): articleRepository.deleteAll(relatedArticles) 호출
  • 로그 메시지 (line 95): "연관 게시글 소프트 삭제"로 명시적 확인

deleteAll()@SQLDelete로 인해 UPDATE articles SET is_deleted = true, deleted_at = CURRENT_TIMESTAMP WHERE id = ? 쿼리를 실행하므로, 테스트 이름 "관련 게시글도 소프트 삭제"는 정확합니다.

monew/src/main/java/com/spring/monew/interest/service/impl/InterestServiceImpl.java (1)

38-40: 유사도 체크 강화 확인됨

similarNames가 비어있지 않을 때 예외를 발생시키도록 로직이 강화되었습니다. 이는 중복/유사 관심사 생성을 방지하는 좋은 개선입니다.

monew/src/test/java/com/spring/monew/interest/service/InterestIntegrationTest.java (1)

129-175: 소프트 삭제 검증 테스트가 포괄적으로 작성됨

관심사 삭제 시 연관 기사들도 소프트 삭제되는지 확인하는 테스트가 잘 작성되었습니다.

장점:

  1. Native query로 DB의 실제 is_deleted 플래그 확인
  2. Repository 레벨에서 @SQLRestriction 필터링 동작 확인
  3. flush()/clear()로 영속성 컨텍스트 관리
  4. 관심사와 2개 기사 모두 검증

테스트 커버리지가 우수합니다.

monew/src/main/java/com/spring/monew/interest/domain/Interest.java (2)

28-29: 소프트 삭제 구현이 올바르게 적용됨

Hibernate의 @SQLDelete@SQLRestriction 어노테이션을 사용한 소프트 삭제 구현이 다른 엔티티(Article, Comment, User)와 일관되게 적용되었습니다.

  • @SQLDelete: repository.delete() 호출 시 UPDATE 쿼리 실행
  • @SQLRestriction: 조회 쿼리에 자동으로 is_deleted = false 조건 추가

50-63: 감사 및 소프트 삭제 필드 추가 확인

새로 추가된 필드들이 적절하게 구성되었습니다:

  • updatedAt: @UpdateTimestamp로 자동 갱신
  • isDeleted: 기본값 false로 설정
  • deletedAt: nullable로 삭제 시점 기록

스키마 정의(schema.sql, schema-h2.sql)와 일치합니다.

monew/src/main/resources/application.yml (3)

63-66: 로깅 파일 설정 적절함

로그 파일 경로, 이름, 최대 보관 기간(7일)이 명확하게 설정되었습니다. 환경 변수로 오버라이드 가능한 구조도 좋습니다.


108-114: AWS S3 설정 적절함

S3 리전, 버킷, 인증 정보가 환경 변수에서 주입되도록 구성되어 보안상 안전합니다. 기사 백업용 버킷과 로그 백업용 버킷을 분리한 것도 좋은 접근입니다.


38-42: 배치 트리거 설정 검증 완료

설정이 올바르게 구성되어 있습니다. YAML 파일의 spring.batch.trigger.enabled: true는 Java 코드의 @ConditionalOnProperty(name = "spring.batch.trigger.enabled", havingValue = "true") 와 정확히 일치하며, BatchJobController가 이 속성으로 조건부 활성화됩니다. spring.batch.job.enabled: false와 함께 수동 트리거 API 구현이라는 PR 목표와 완벽하게 부합합니다.

monew/src/main/resources/schema.sql (3)

67-70: interests 테이블 소프트 삭제 컬럼 추가 확인

updated_at, is_deleted, deleted_at 컬럼이 Interest 엔티티 정의와 일치하게 추가되었습니다. TIMESTAMP WITH TIME ZONE 타입 사용도 적절합니다.


111-114: ON DELETE CASCADE 제거가 소프트 삭제 패턴에 적합함

fk_article_interest에서 ON DELETE CASCADE를 제거한 것은 소프트 삭제 구현에 필수적인 변경입니다.

이유:

  • 소프트 삭제는 UPDATE 쿼리로 동작하므로 DB의 ON DELETE CASCADE가 트리거되지 않습니다.
  • 대신 InterestServiceImpl.removeInterest()에서 애플리케이션 레벨로 연관 기사를 삭제합니다.
  • ON UPDATE CASCADE는 유지되어 interest ID 변경 시 전파됩니다.

주의사항:

  • 이제 관심사 삭제 로직이 애플리케이션 코드에 의존합니다.
  • 직접 SQL로 관심사를 삭제하면 기사가 고아(orphan) 상태로 남을 수 있습니다.

283-284: 알림 조회 성능 개선을 위한 인덱스 추가

idx_notif_user_confirm_created_desc 인덱스는 사용자별 알림 조회 쿼리(확인 여부와 생성 시간 기준 정렬)의 성능을 개선합니다. 복합 인덱스 컬럼 순서도 적절합니다.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
monew/src/main/java/com/spring/monew/batch/controller/BatchJobController.java (1)

44-76: 배치 엔드포인트 보안 설정 누락 - 즉시 수정 필요

이전 리뷰에서 지적된 보안 이슈가 여전히 해결되지 않았습니다. SecurityConfig.java 42번 라인의 .permitAll() 설정으로 인해 인증 없이 누구나 다음의 민감한 배치 작업을 실행할 수 있습니다:

  • 기사 백업 (모든 기사를 S3에 백업)
  • 로그 백업 (애플리케이션 로그를 S3에 업로드)
  • 기사 정리 (30일 이상 지난 삭제된 기사 물리 삭제)

SecurityConfig에서 /api/batch/** 경로를 hasRole('ADMIN')으로 변경하거나, 각 메서드에 @PreAuthorize("hasRole('ADMIN')") 애노테이션을 추가해야 합니다.

🧹 Nitpick comments (3)
monew/src/main/java/com/spring/monew/batch/config/ArticleBackupBatchConfig.java (1)

63-63: 타임존 설정을 외부화하는 것을 권장합니다.

Asia/Seoul 타임존이 하드코딩되어 있어 배포 환경이 변경될 경우 코드 수정이 필요합니다.

다음과 같이 설정 파일에서 주입받도록 개선할 수 있습니다:

+    @Value("${application.timezone:Asia/Seoul}")
+    private String timezone;
+
     @Bean
     @StepScope
     public JpaPagingItemReader<Article> articleBackupReader(
             @Value("#{jobParameters['triggerType']}") String triggerType
     ) {
-        ZoneId zoneId = ZoneId.of("Asia/Seoul");
+        ZoneId zoneId = ZoneId.of(timezone);
monew/src/main/java/com/spring/monew/batch/service/BatchJobService.java (2)

54-90: 코드 중복을 줄이는 리팩토링을 고려해보세요.

triggerArticleBackupJob()triggerLogBackupJob() 메서드가 triggerNewsCollectionJob()와 거의 동일한 구조를 가지고 있습니다.

공통 로직을 추출하여 중복을 줄일 수 있습니다:

private BatchJobTriggerResponse triggerJob(Job job, String jobName) {
    log.info("{} 수동 실행 요청", jobName);
    
    JobParameters jobParameters = new JobParametersBuilder()
            .addString("triggerType", "MANUAL")
            .addLong("triggerTime", System.currentTimeMillis())
            .toJobParameters();
    
    try {
        JobExecution jobExecution = jobLauncher.run(job, jobParameters);
        log.info("{} 실행 성공 - executionId: {}, status: {}",
                jobName, jobExecution.getId(), jobExecution.getStatus());
        return BatchJobTriggerResponse.from(jobExecution);
    } catch (Exception e) {
        log.error("{} 실행 실패", jobName, e);
        throw new RuntimeException("배치 작업 실행 실패", e);
    }
}

public BatchJobTriggerResponse triggerArticleBackupJob() {
    return triggerJob(articleBackupJob, "articleBackupJob");
}

92-103: 정리 작업 트리거 패턴이 다른 배치 작업과 일관성이 없습니다.

triggerArticleCleanupJob()이 다른 배치 트리거 메서드와 다른 패턴을 사용합니다:

  • 다른 메서드: JobLauncher.run(job, params) 사용 → Spring Batch 인프라 활용, JobExecution 추적 가능
  • 이 메서드: articleCleanupScheduler.deleteSoftDeletedArticles() 직접 호출 → 추적 불가

또한 Line 98의 CleanupTriggerResponse.success("articleCleanupJob", 0, 0)에서 성공/실패 카운트가 하드코딩되어 있어 실제 처리 결과를 반영하지 못합니다.

설계 의도를 확인하고, 가능하다면 ArticleCleanup도 Spring Batch Job으로 전환하거나, deleteSoftDeletedArticles()가 실제 처리 건수를 반환하도록 개선하는 것을 권장합니다.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between beec253 and d96d740.

📒 Files selected for processing (6)
  • monew/src/main/java/com/spring/monew/batch/config/ArticleBackupBatchConfig.java (1 hunks)
  • monew/src/main/java/com/spring/monew/batch/controller/BatchJobController.java (2 hunks)
  • monew/src/main/java/com/spring/monew/batch/dto/response/CleanupTriggerResponse.java (1 hunks)
  • monew/src/main/java/com/spring/monew/batch/service/BatchJobService.java (3 hunks)
  • monew/src/main/java/com/spring/monew/batch/writer/ArticleBackupWriter.java (1 hunks)
  • monew/src/main/resources/application.yml (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • monew/src/main/resources/application.yml
🧰 Additional context used
🧬 Code graph analysis (3)
monew/src/main/java/com/spring/monew/batch/writer/ArticleBackupWriter.java (3)
monew/src/main/java/com/spring/monew/batch/config/ArticleBackupBatchConfig.java (1)
  • Slf4j (29-95)
monew/src/main/java/com/spring/monew/batch/processor/ArticleBackupProcessor.java (1)
  • Component (11-28)
monew/src/main/java/com/spring/monew/batch/scheduler/ArticleBackupScheduler.java (1)
  • Component (14-38)
monew/src/main/java/com/spring/monew/batch/config/ArticleBackupBatchConfig.java (1)
monew/src/main/java/com/spring/monew/auth/config/SecurityConfig.java (1)
  • RequiredArgsConstructor (17-60)
monew/src/main/java/com/spring/monew/batch/service/BatchJobService.java (1)
monew/src/main/java/com/spring/monew/batch/exception/BatchJobExecutionNotFoundException.java (1)
  • BatchJobExecutionNotFoundException (3-7)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build-and-test
🔇 Additional comments (3)
monew/src/main/java/com/spring/monew/batch/dto/response/CleanupTriggerResponse.java (1)

5-33: LGTM! 깔끔한 DTO 구현입니다.

Record 패턴을 사용한 불변 DTO로 잘 설계되었으며, 정적 팩토리 메서드로 성공/실패 응답을 명확하게 구분하고 있습니다.

monew/src/main/java/com/spring/monew/batch/config/ArticleBackupBatchConfig.java (1)

71-81: MANUAL 트리거의 날짜 범위 검증이 필요합니다.

수동 트리거 시 tomorrow까지 포함하는 로직이 의도된 동작인지 확인이 필요합니다. PR 요약에는 "어제+오늘 기사를 백업"이라고 명시되어 있으나, 코드는 내일(미래)까지의 범위를 포함합니다.

  • 의도: 수동 실행 중 생성되는 오늘의 기사를 모두 포함하기 위함
  • 의문: tomorrow 범위는 아직 생성되지 않은 미래 시점이므로 불필요할 수 있음

스케줄 트리거(어제오늘)와의 차이가 명확한지, 혹은 수동 트리거도 어제오늘 범위로 통일해야 하는지 검토해주세요.

monew/src/main/java/com/spring/monew/batch/writer/ArticleBackupWriter.java (1)

26-26: 동시 실행 시 데이터 혼합 가능성을 검증해주세요.

aggregatedArticles가 싱글톤 컴포넌트의 인스턴스 필드로 선언되어 있어, 여러 배치 작업이 동시에 실행될 경우 서로 다른 작업의 데이터가 혼합될 수 있습니다.

Spring Batch가 동일 Writer 인스턴스를 여러 Job 실행에서 재사용하는지, 그리고 동시 실행을 방지하는 설정이 있는지 확인해주세요. 만약 동시 실행 가능성이 있다면 ThreadLocal<List<ArticleBackupDto>> 또는 Step별 격리된 저장소 사용을 권장합니다.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
monew/src/main/java/com/spring/monew/article/repository/ArticleRepository.java (2)

30-31: 대량 데이터 환경에서의 메모리 사용을 고려하세요.

페이지네이션 없이 모든 sourceUrl을 조회하고 있습니다. 기사 수가 많아질 경우 메모리 부담이 발생할 수 있습니다. 현재 백업/배치 용도로 사용된다면 허용 가능하지만, 향후 데이터 증가 시 페이지네이션이나 스트리밍 방식을 고려해보세요.


33-48: 시간 범위 쿼리가 방어적으로 잘 구현되었습니다.

deleted_at IS NOT NULL 체크로 데이터 무결성을 보장하고, Instant 타입으로 타임존 일관성도 확보되었습니다. 포괄적 범위(>=, <=) 사용도 적절합니다.

다만 deleted_at 컬럼에 인덱스가 없다면, 테이블 크기 증가 시 이러한 시간 범위 쿼리의 성능이 저하될 수 있습니다. 프로덕션 환경에서 deleted_at 컬럼에 인덱스 추가를 고려해보세요.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d96d740 and 3ed3ac0.

📒 Files selected for processing (3)
  • monew/src/main/java/com/spring/monew/article/repository/ArticleRepository.java (1 hunks)
  • monew/src/main/java/com/spring/monew/batch/scheduler/ArticleCleanupScheduler.java (1 hunks)
  • monew/src/main/java/com/spring/monew/batch/service/BatchJobService.java (3 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-10-30T04:47:15.335Z
Learnt from: Namsoo315
Repo: codeit-monew/sb05-monew-team7 PR: 59
File: monew/src/main/java/com/spring/monew/comment/repository/CommentRepository.java:29-30
Timestamp: 2025-10-30T04:47:15.335Z
Learning: In the file monew/src/main/java/com/spring/monew/comment/repository/CommentRepository.java, the method `findIncludingDeleted(Param("commentId") UUID commentId)` is intentionally named this way even though the query `SELECT * FROM comments c WHERE c.id = :commentId AND c.is_deleted = true` only returns deleted records (is_deleted = true), not all records including deleted ones. This naming is by design and should not be flagged.

Applied to files:

  • monew/src/main/java/com/spring/monew/article/repository/ArticleRepository.java
🧬 Code graph analysis (1)
monew/src/main/java/com/spring/monew/batch/scheduler/ArticleCleanupScheduler.java (2)
monew/src/main/java/com/spring/monew/auth/config/SecurityConfig.java (1)
  • RequiredArgsConstructor (17-60)
monew/src/main/java/com/spring/monew/batch/service/BatchJobService.java (1)
  • Slf4j (22-142)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build-and-test
🔇 Additional comments (6)
monew/src/main/java/com/spring/monew/article/repository/ArticleRepository.java (5)

4-11: 필요한 import가 모두 적절하게 추가되었습니다.

새로운 메서드들의 타입과 어노테이션에 필요한 import들이 정확하게 추가되었습니다.


20-22: 하드 삭제 메서드가 올바르게 구현되었습니다.

@Modifying(clearAutomatically = true) 설정으로 persistence context가 자동으로 클리어되어 안전합니다. 트랜잭션 관리는 서비스 레이어에서 처리되고 있습니다.


24-25: 효율적인 존재 여부 확인 구현입니다.

EXISTS 서브쿼리를 사용하여 soft-delete 상태와 관계없이 효율적으로 존재 여부를 확인합니다.


27-28: 과거 리뷰 이슈가 정확히 해결되었습니다.

is_deleted = true 필터가 추가되어 이제 CommentRepository.findIncludingDeleted와 일관된 패턴으로 삭제된 레코드만 조회합니다.


50-50: Spring Data JPA 규칙에 따라 적절하게 정의되었습니다.

메서드 네이밍 규칙을 따르고 있으며, Article 엔티티의 @SQLRestriction에 의해 자동으로 soft-delete된 레코드가 필터링됩니다.

monew/src/main/java/com/spring/monew/batch/scheduler/ArticleCleanupScheduler.java (1)

27-64: MDC 컨텍스트 관리와 예외 격리 흐름 좋습니다

한 번 생성한 requestId를 finally에서 정리하고, 기사 단위 try/catch로 실패를 격리해 두신 점이 안정적으로 보입니다.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (5)
monew/src/main/java/com/spring/monew/interest/repository/InterestRepository.java (1)

18-19: 성능 및 메서드 네이밍 개선을 고려하세요.

두 가지 개선 사항을 제안합니다:

  1. 성능: 삭제된 관심사가 많을 경우 is_deleted = true 조건으로 모든 레코드를 스캔하고 정렬하는 것은 비효율적일 수 있습니다. (is_deleted, deleted_at) 복합 인덱스 추가를 권장합니다.

  2. 네이밍: findFirstDeleted()는 모호합니다("first"가 최초 삽입? 최초 삭제? 최근 삭제?). 쿼리 동작(ORDER BY deleted_at DESC LIMIT 1)을 고려하면 findMostRecentlyDeleted() 또는 findLatestDeleted()가 더 명확합니다.

마이그레이션 스크립트에 인덱스 추가 예시:

CREATE INDEX idx_interests_deleted ON interests(is_deleted, deleted_at DESC);

메서드명 변경:

- @Query(value = "SELECT * FROM interests WHERE is_deleted = true ORDER BY deleted_at DESC LIMIT 1", nativeQuery = true)
- Optional<Interest> findFirstDeleted();
+ @Query(value = "SELECT * FROM interests WHERE is_deleted = true ORDER BY deleted_at DESC LIMIT 1", nativeQuery = true)
+ Optional<Interest> findMostRecentlyDeleted();
monew/src/main/java/com/spring/monew/article/service/impl/ArticleServiceImpl.java (4)

226-242: DTO를 Map으로 변환하는 불필요한 오버헤드 제거

Line 232에서 ArticleBackupDtoMap<String, Object>로 변환하는데, 이는 타입 안전성을 잃고 성능 오버헤드를 추가합니다. restoreArticle 메서드에서 DTO를 직접 사용하면 더 효율적이고 안전합니다.

다음과 같이 리팩토링하세요:

-  private List<Map<String, Object>> findBackupsForDate(LocalDate date) {
+  private List<ArticleBackupDto> findBackupsForDate(LocalDate date) {
     try {
       List<ArticleBackupDto> backupDtos = s3BackupService.downloadBackup(date);
-      
-      List<Map<String, Object>> backups = new ArrayList<>();
-      for (ArticleBackupDto dto : backupDtos) {
-        Map<String, Object> backupMap = objectMapper.convertValue(dto, new TypeReference<Map<String, Object>>() {});
-        backups.add(backupMap);
-      }
-      
-      log.info("[복원] {} 날짜의 백업 파일에서 {}개 기사 발견", date, backups.size());
-      return backups;
+      log.info("[복원] {} 날짜의 백업 파일에서 {}개 기사 발견", date, backupDtos.size());
+      return backupDtos;
     } catch (Exception e) {
       log.warn("백업 검색 실패 for date {}: {}", date, e.getMessage());
       return new ArrayList<>();
     }
   }

그리고 findMissingArticlesrestoreArticle 메서드도 ArticleBackupDto를 사용하도록 수정해주세요.


320-323: 구체적인 예외 타입 사용 권장

Line 322에서 일반 RuntimeException으로 래핑하는 대신, 더 구체적인 예외 타입을 사용하면 예외 처리 및 디버깅이 용이합니다.

커스텀 예외를 생성하거나 기존 예외를 사용하세요:

     } catch (Exception e) {
       log.error("기사 복원 실패: {}", backupData, e);
-      throw new RuntimeException("기사 복원 실패", e);
+      throw new ArticleRestoreException("기사 복원 실패: " + backupData.get("id"), e);
     }

ArticleRestoreException을 새로 생성하거나, 기존의 적절한 예외를 사용할 수 있습니다.


326-337: 타임스탬프 파싱 실패 시 명시적 처리 고려

parseInstant 메서드는 null이나 잘못된 값에 대해 Instant.now()를 반환합니다. 이는 백업 데이터의 원본 타임스탬프 정보를 조용히 손실시켜 데이터 무결성 문제를 일으킬 수 있습니다. 복원 작업의 특성상 원본 시간 정보 보존이 중요합니다.

null이나 잘못된 형식에 대해 예외를 발생시키거나, 최소한 경고 로그를 남기는 것을 고려하세요:

   private Instant parseInstant(Object value) {
     if (value == null) {
+      log.warn("타임스탬프 값이 null입니다. 현재 시간을 사용합니다.");
       return Instant.now();
     }
     if (value instanceof String) {
-      return Instant.parse((String) value);
+      try {
+        return Instant.parse((String) value);
+      } catch (Exception e) {
+        log.warn("타임스탬프 파싱 실패: {}. 현재 시간을 사용합니다.", value);
+        return Instant.now();
+      }
     }
     if (value instanceof Number) {
       return Instant.ofEpochMilli(((Number) value).longValue());
     }
+    log.warn("지원하지 않는 타임스탬프 형식: {}. 현재 시간을 사용합니다.", value.getClass());
     return Instant.now();
   }

339-358: 기본 Interest 생성 전략 재검토

Line 355에서 활성 Interest가 전혀 없을 때 "복원됨"이라는 이름의 기본 Interest를 생성합니다. 이는 임시 방편으로 보이며, 실제 운영 환경에서는 시스템이 일관성 없는 상태에 있음을 나타낼 수 있습니다.

다음 중 하나를 고려하세요:

  1. 예외 발생: Interest가 없는 것은 시스템 설정 오류이므로 복원을 실패시키는 것이 더 명확할 수 있습니다.
throw new IllegalStateException("복원에 필요한 Interest가 시스템에 존재하지 않습니다.");
  1. 관리자 알림: 기본 Interest를 생성하되, 관리자에게 알림을 보내 수동 개입을 요청합니다.
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3ed3ac0 and 3706228.

📒 Files selected for processing (3)
  • monew/src/main/java/com/spring/monew/article/service/impl/ArticleServiceImpl.java (3 hunks)
  • monew/src/main/java/com/spring/monew/interest/domain/Interest.java (3 hunks)
  • monew/src/main/java/com/spring/monew/interest/repository/InterestRepository.java (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • monew/src/main/java/com/spring/monew/interest/domain/Interest.java
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-10-30T04:47:15.335Z
Learnt from: Namsoo315
Repo: codeit-monew/sb05-monew-team7 PR: 59
File: monew/src/main/java/com/spring/monew/comment/repository/CommentRepository.java:29-30
Timestamp: 2025-10-30T04:47:15.335Z
Learning: In the file monew/src/main/java/com/spring/monew/comment/repository/CommentRepository.java, the method `findIncludingDeleted(Param("commentId") UUID commentId)` is intentionally named this way even though the query `SELECT * FROM comments c WHERE c.id = :commentId AND c.is_deleted = true` only returns deleted records (is_deleted = true), not all records including deleted ones. This naming is by design and should not be flagged.

Applied to files:

  • monew/src/main/java/com/spring/monew/interest/repository/InterestRepository.java
🧬 Code graph analysis (1)
monew/src/main/java/com/spring/monew/article/service/impl/ArticleServiceImpl.java (2)
monew/src/main/java/com/spring/monew/article/exception/ArticleNotFoundException.java (1)
  • ArticleNotFoundException (5-9)
monew/src/main/java/com/spring/monew/common/exception/GlobalExceptionHandler.java (1)
  • Slf4j (17-107)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build-and-test
🔇 Additional comments (2)
monew/src/main/java/com/spring/monew/interest/repository/InterestRepository.java (1)

15-16: findIncludingDeleted 메서드의 쿼리가 다른 저장소와 불일치합니다.

InterestRepository의 findIncludingDeleted 메서드는 SELECT * FROM interests WHERE id = :interestId 쿼리를 사용하여 삭제 상태를 필터링하지 않으므로, 삭제된 레코드와 활성 레코드를 구별하지 않습니다. 반면 ArticleRepository와 CommentRepository의 동일 메서드는 모두 AND is_deleted = true 조건을 포함합니다.

조건을 추가하여 일관성을 맞추세요:

@Query(value = "SELECT * FROM interests WHERE id = :interestId AND is_deleted = true", nativeQuery = true)
Optional<Interest> findIncludingDeleted(@Param("interestId") UUID interestId);
⛔ Skipped due to learnings
Learnt from: Namsoo315
Repo: codeit-monew/sb05-monew-team7 PR: 59
File: monew/src/main/java/com/spring/monew/comment/repository/CommentRepository.java:29-30
Timestamp: 2025-10-30T04:47:15.335Z
Learning: In the file monew/src/main/java/com/spring/monew/comment/repository/CommentRepository.java, the method `findIncludingDeleted(Param("commentId") UUID commentId)` is intentionally named this way even though the query `SELECT * FROM comments c WHERE c.id = :commentId AND c.is_deleted = true` only returns deleted records (is_deleted = true), not all records including deleted ones. This naming is by design and should not be flagged.
monew/src/main/java/com/spring/monew/article/service/impl/ArticleServiceImpl.java (1)

146-159: 검증 완료 - 우려사항 없음

Article 엔티티의 @SQLRestriction("is_deleted = false")@SQLDelete 어노테이션을 확인했습니다.

  • findById()는 자동으로 소프트 삭제된 기사를 제외합니다.
  • delete() 호출 시 @SQLDelete 어노테이션이 is_deleted=true로 업데이트를 수행합니다.
  • 이미 삭제된 기사를 재삭제하려고 하면 ArticleNotFoundException이 발생하여 404가 반환됩니다.

코드가 PR 요구사항과 일치하게 올바르게 구현되었습니다.

Comment on lines +206 to +219
try {
List<Map<String, Object>> backupDataList = findBackupsForDate(currentDate);
List<Map<String, Object>> missingArticles = findMissingArticles(backupDataList);

for (Map<String, Object> backupData : missingArticles) {
Article restored = restoreArticle(backupData);
restoredArticleIds.add(restored.getId());
}
} catch (Exception e) {
log.warn("날짜 {}의 백업 복원 중 오류 발생: {}", currentDate, e.getMessage());
}

currentDate = currentDate.plusDays(1);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

복원 실패 정보를 결과에 포함하세요

현재 구현은 특정 날짜의 백업 복원 실패 시 경고 로그만 남기고 계속 진행합니다. 사용자는 어느 날짜가 실패했는지 알 수 없고, ArticleRestoreResultDto에도 실패 정보가 포함되지 않습니다. 부분 성공 시나리오에서 명확한 피드백이 필요합니다.

실패한 날짜를 추적하고 결과 DTO에 포함하는 것을 고려해주세요:

     List<UUID> restoredArticleIds = new ArrayList<>();
+    List<LocalDate> failedDates = new ArrayList<>();
     LocalDate currentDate = fromDate.atZone(ZoneOffset.UTC).toLocalDate();
     LocalDate endDate = toDate.atZone(ZoneOffset.UTC).toLocalDate();

     while (!currentDate.isAfter(endDate)) {
       try {
         List<Map<String, Object>> backupDataList = findBackupsForDate(currentDate);
         List<Map<String, Object>> missingArticles = findMissingArticles(backupDataList);
         
         for (Map<String, Object> backupData : missingArticles) {
           Article restored = restoreArticle(backupData);
           restoredArticleIds.add(restored.getId());
         }
       } catch (Exception e) {
         log.warn("날짜 {}의 백업 복원 중 오류 발생: {}", currentDate, e.getMessage());
+        failedDates.add(currentDate);
       }
       
       currentDate = currentDate.plusDays(1);
     }

그리고 ArticleRestoreResultDtofailedDates 필드를 추가해주세요.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
monew/src/main/java/com/spring/monew/article/service/impl/ArticleServiceImpl.java
around lines 206-219, the catch block only logs backup restore errors so callers
and the returned ArticleRestoreResultDto don't know which dates failed; update
the method to collect failedDates: initialize a List<LocalDate> failedDates
before the loop, in the catch block add currentDate (and optionally error
message) to failedDates instead of only logging, and after the loop populate and
return an ArticleRestoreResultDto that includes this failedDates field (also add
the failedDates property to ArticleRestoreResultDto with appropriate
getter/setter/constructor and update any builders/serializers as needed).

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
monew/src/main/resources/schema-h2.sql (1)

26-26: 타임존 처리 불일치: H2 schema의 interests.deleted_at 컬럼 타입 수정 필요

검증 결과, H2 schema의 deleted_at 컬럼이 PostgreSQL schema와 일치하지 않습니다. PostgreSQL에서는 TIMESTAMP WITH TIME ZONE을 사용하지만, H2에서는 TIMESTAMP만 사용하고 있으며, 이는 같은 테이블의 created_at, updated_at 컬럼과도 불일치합니다.

현재 상태:

  • PostgreSQL: deleted_at TIMESTAMP WITH TIME ZONE
  • H2 (line 26): deleted_at TIMESTAMP

다음 수정을 적용하세요:

- deleted_at TIMESTAMP
+ deleted_at TIMESTAMP WITH TIME ZONE
🧹 Nitpick comments (6)
monew/src/main/java/com/spring/monew/batch/writer/ArticleBackupWriter.java (2)

31-34: 불필요한 캐스팅 및 null 필터 제거 가능

Chunk<? extends ArticleBackupDto>의 아이템은 이미 ArticleBackupDto 타입이므로 명시적 캐스팅이 불필요하며, ArticleBackupProcessor는 예외 발생 시 실패 처리하므로 null을 반환하지 않습니다.

-        List<ArticleBackupDto> articles = chunk.getItems().stream()
-            .map(dto -> (ArticleBackupDto) dto)
-            .filter(dto -> dto != null)
-            .toList();
+        List<ArticleBackupDto> articles = new ArrayList<>(chunk.getItems());

또는 null 체크를 유지하려면:

-        List<ArticleBackupDto> articles = chunk.getItems().stream()
-            .map(dto -> (ArticleBackupDto) dto)
-            .filter(dto -> dto != null)
-            .toList();
+        List<ArticleBackupDto> articles = chunk.getItems().stream()
+            .filter(dto -> dto != null)
+            .toList();

58-58: 타임존 하드코딩으로 인한 유지보수 위험

"Asia/Seoul" 타임존이 Writer와 Reader(ArticleBackupBatchConfig line 59)에 중복 하드코딩되어 있습니다. 향후 타임존 변경 시 여러 곳을 수정해야 하며, 불일치 시 백업 날짜 계산 오류가 발생할 수 있습니다.

권장 사항:
타임존을 설정 파일(application.yml)에서 주입하거나 상수로 중앙 관리하세요.

공통 상수 정의:

// BatchConstants.java (새 파일)
public class BatchConstants {
    public static final ZoneId BACKUP_TIMEZONE = ZoneId.of("Asia/Seoul");
}

Writer에서 사용:

+import static com.spring.monew.batch.BatchConstants.BACKUP_TIMEZONE;
+
-        LocalDate backupDate = LocalDate.now(ZoneId.of("Asia/Seoul")).minusDays(1);
+        LocalDate backupDate = LocalDate.now(BACKUP_TIMEZONE).minusDays(1);

또는 설정 주입 방식:

@Value("${batch.backup.timezone:Asia/Seoul}")
private String backupTimezone;

// afterStep()에서
LocalDate backupDate = LocalDate.now(ZoneId.of(backupTimezone)).minusDays(1);
monew/src/main/java/com/spring/monew/article/service/impl/ArticleServiceImpl.java (3)

232-248: 타입 안전성을 위해 Map 대신 DTO를 직접 사용하세요.

Line 238에서 ArticleBackupDtoMap<String, Object>로 변환하고 있습니다. 이후 메서드들(findMissingArticles, restoreArticle)도 Map으로 작업합니다. 이는 타입 안전성을 잃고 런타임 오류 가능성을 높입니다.

ArticleBackupDto를 직접 사용하도록 리팩토링하는 것을 권장합니다:

- private List<Map<String, Object>> findBackupsForDate(LocalDate date) {
+ private List<ArticleBackupDto> findBackupsForDate(LocalDate date) {
    try {
      List<ArticleBackupDto> backupDtos = s3BackupService.downloadBackup(date);
-     
-     List<Map<String, Object>> backups = new ArrayList<>();
-     for (ArticleBackupDto dto : backupDtos) {
-       Map<String, Object> backupMap = objectMapper.convertValue(dto, new TypeReference<Map<String, Object>>() {});
-       backups.add(backupMap);
-     }
-     
-     log.info("[복원] {} 날짜의 백업 파일에서 {}개 기사 발견", date, backups.size());
-     return backups;
+     log.info("[복원] {} 날짜의 백업 파일에서 {}개 기사 발견", date, backupDtos.size());
+     return backupDtos;
    } catch (Exception e) {
      log.warn("백업 검색 실패 for date {}: {}", date, e.getMessage());
      return new ArrayList<>();
    }
  }

그리고 findMissingArticles, restoreArticle 메서드도 이에 맞춰 수정하세요.


282-334: 복원 로직이 포괄적으로 구현되었으나 사이드 이펙트를 문서화하세요.

Interest 처리 로직(lines 300-311)이 잘 설계되었습니다. 활성 Interest를 찾고, 없으면 삭제된 Interest를 복원하며, 그것도 없으면 기본 Interest를 생성하는 다단계 폴백 전략이 적절합니다.

참고: Line 305에서 기사 복원 중 삭제된 Interest를 자동으로 복원하는 것은 중요한 사이드 이펙트입니다. 이는 의도된 동작으로 보이지만, 메서드나 클래스 수준의 JavaDoc에 이 동작을 명시하는 것을 권장합니다.

메서드에 JavaDoc을 추가하여 사이드 이펙트를 문서화하세요:

+ /**
+  * 백업 데이터로부터 단일 기사를 복원합니다.
+  * 
+  * @param backupData 백업된 기사 데이터
+  * @return 복원된 기사 엔티티
+  * @throws RuntimeException 복원 중 오류 발생 시
+  * 
+  * 참고: 연관된 Interest가 삭제된 경우 자동으로 복원합니다.
+  */
  private Article restoreArticle(Map<String, Object> backupData) {

349-368: 기본 Interest 생성 로직이 적절하나 효율성 개선이 가능합니다.

Line 350에서 findAll().stream().findFirst()를 사용하는 것은 모든 Interest를 로드하므로 비효율적입니다.

InterestRepository에 전용 메서드를 추가하여 효율성을 개선하세요:

// InterestRepository에 추가
Optional<Interest> findFirstByOrderByCreatedAtAsc();

그리고 이를 사용:

  private Interest getOrCreateDefaultInterest() {
-   return interestRepository.findAll().stream()
-       .findFirst()
+   return interestRepository.findFirstByOrderByCreatedAtAsc()
        .orElseGet(() -> {
          // ... 나머지 로직
        });
  }

참고: "복원됨" 이름과 ["복원"] 키워드는 엣지 케이스용 하드코딩된 값이지만, 이런 상황(활성 및 삭제된 Interest가 모두 없음)은 극히 드물므로 수용 가능합니다.

monew/src/main/java/com/spring/monew/batch/service/BatchJobService.java (1)

95-99: 성공/실패 건수를 로그에 함께 남겨주세요.
수동 정리 결과를 counts 배열로 돌려받지만, 현재 로그는 건수 정보를 포함하지 않아 모니터링 시 직전 실행 성과를 로그만으로 파악하기 어렵습니다. 스케줄러 로그 포맷과 맞춰 성공/실패 건수를 함께 출력해 두면 추적성이 좋아집니다.

다음과 같이 개선을 제안합니다:

-            log.info("articleCleanupJob 실행 완료");
+            log.info("articleCleanupJob 실행 완료 - 성공: {}, 실패: {}", counts[0], counts[1]);
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3706228 and cd19dd1.

📒 Files selected for processing (5)
  • monew/src/main/java/com/spring/monew/article/service/impl/ArticleServiceImpl.java (3 hunks)
  • monew/src/main/java/com/spring/monew/batch/scheduler/ArticleCleanupScheduler.java (1 hunks)
  • monew/src/main/java/com/spring/monew/batch/service/BatchJobService.java (3 hunks)
  • monew/src/main/java/com/spring/monew/batch/writer/ArticleBackupWriter.java (1 hunks)
  • monew/src/main/resources/schema-h2.sql (8 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
monew/src/main/java/com/spring/monew/article/service/impl/ArticleServiceImpl.java (1)
monew/src/main/java/com/spring/monew/article/exception/ArticleNotFoundException.java (1)
  • ArticleNotFoundException (5-9)
monew/src/main/java/com/spring/monew/batch/writer/ArticleBackupWriter.java (3)
monew/src/main/java/com/spring/monew/batch/config/ArticleBackupBatchConfig.java (1)
  • Slf4j (29-95)
monew/src/main/java/com/spring/monew/batch/processor/ArticleBackupProcessor.java (1)
  • Component (11-28)
monew/src/main/java/com/spring/monew/batch/scheduler/ArticleBackupScheduler.java (1)
  • Component (14-38)
monew/src/main/java/com/spring/monew/batch/scheduler/ArticleCleanupScheduler.java (1)
monew/src/main/java/com/spring/monew/batch/service/BatchJobService.java (1)
  • Slf4j (22-142)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build-and-test
🔇 Additional comments (4)
monew/src/main/java/com/spring/monew/article/service/impl/ArticleServiceImpl.java (4)

3-50: 의존성 및 필드 선언이 적절합니다.

백업/복구 및 감사 로깅 기능에 필요한 의존성들이 올바르게 추가되었습니다. @RequiredArgsConstructor를 통한 의존성 주입 패턴도 일관성 있게 유지되고 있습니다.


161-182: 하드 삭제 로직이 적절하게 구현되었습니다.

READ_COMMITTED 격리 수준으로 변경된 점이 좋습니다. 삭제 순서(댓글 좋아요 → 댓글 → 조회수 → 기사)가 외래 키 제약 조건을 올바르게 고려하고 있으며, 감사 로그에 삭제된 데이터 개수를 포함하는 것도 추적성 측면에서 유용합니다.

참고: Line 169-170의 카운트와 실제 삭제 사이에 이론적으로 TOCTOU 경합이 있을 수 있지만, 감사 로그 목적상 근사치로 충분하며 READ_COMMITTED는 성능과 정확성의 합리적인 균형점입니다.


250-279: 중복 제거 로직이 효율적으로 구현되었습니다.

Line 260에서 findExistingSourceUrls(backupUrls)를 사용하여 백업 내 URL만 조회하는 방식으로 메모리 효율성 문제가 해결되었습니다. 이전 리뷰에서 제기된 전체 URL 로딩 문제가 적절히 개선되었습니다.


146-159: 검증 완료: 소프트 삭제가 올바르게 구현되었습니다.

Article 엔티티에서 Hibernate의 @SQLDelete 어노테이션으로 소프트 삭제가 올바르게 구현되어 있습니다. articleRepository.delete(article) 호출 시 is_deleted = truedeleted_at = CURRENT_TIMESTAMP를 설정하는 UPDATE 쿼리가 실행되므로 물리적 삭제가 아닌 논리적 삭제가 수행됩니다. @SQLRestriction("is_deleted = false")으로 인해 삭제된 데이터는 조회에서 자동으로 제외됩니다.

Comment on lines +184 to +230
@Override
@Transactional
public ArticleRestoreResultDto restoreArticlesFromBackup(Instant fromDate, Instant toDate, UUID userId) {
String requestId = MDC.get("requestId");
if (fromDate == null || toDate == null) {
throw new IllegalArgumentException("시작 날짜와 종료 날짜는 필수입니다");
}

if (fromDate.isAfter(toDate)) {
throw new IllegalArgumentException("시작 날짜는 종료 날짜보다 이전이어야 합니다");
}

Duration duration = Duration.between(fromDate, toDate);
if (duration.toDays() > 31) {
throw new IllegalArgumentException("날짜 범위는 최대 31일까지만 가능합니다");
}

List<UUID> restoredArticleIds = new ArrayList<>();
List<String> failedDates = new ArrayList<>();
LocalDate currentDate = fromDate.atZone(ZoneOffset.UTC).toLocalDate();
LocalDate endDate = toDate.atZone(ZoneOffset.UTC).toLocalDate();

while (!currentDate.isAfter(endDate)) {
try {
List<Map<String, Object>> backupDataList = findBackupsForDate(currentDate);
List<Map<String, Object>> missingArticles = findMissingArticles(backupDataList);

for (Map<String, Object> backupData : missingArticles) {
Article restored = restoreArticle(backupData);
restoredArticleIds.add(restored.getId());
}
} catch (Exception e) {
log.warn("날짜 {}의 백업 복원 중 오류 발생: {}", currentDate, e.getMessage());
failedDates.add(currentDate.toString());
}

currentDate = currentDate.plusDays(1);
}

if (!failedDates.isEmpty()) {
log.warn("복원 실패한 날짜: {}", String.join(", ", failedDates));
}

log.info("복원 완료: {} 개의 기사 복원됨", restoredArticleIds.size());
auditLogger.logRestore(fromDate, toDate, restoredArticleIds.size(), userId, requestId);
return new ArticleRestoreResultDto(Instant.now(), restoredArticleIds, restoredArticleIds.size());
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

장기 실행 트랜잭션으로 인한 잠금 및 타임아웃 위험이 있습니다.

이 메서드는 최대 31일간의 백업을 단일 트랜잭션으로 복원합니다. 수천 개의 기사를 복원할 경우 트랜잭션이 매우 오래 실행되어 데이터베이스 잠금 문제, 타임아웃, 그리고 롤백 시 많은 작업 손실이 발생할 수 있습니다.

다음 중 하나의 접근 방식을 고려하세요:

옵션 1: 날짜별 독립 트랜잭션 (권장)

  @Override
- @Transactional
  public ArticleRestoreResultDto restoreArticlesFromBackup(Instant fromDate, Instant toDate, UUID userId) {
    // ... validation ...
    
    while (!currentDate.isAfter(endDate)) {
      try {
-       List<Map<String, Object>> backupDataList = findBackupsForDate(currentDate);
-       List<Map<String, Object>> missingArticles = findMissingArticles(backupDataList);
-       
-       for (Map<String, Object> backupData : missingArticles) {
-         Article restored = restoreArticle(backupData);
-         restoredArticleIds.add(restored.getId());
-       }
+       List<UUID> restored = restoreDateInTransaction(currentDate);
+       restoredArticleIds.addAll(restored);
      } catch (Exception e) {
        // ...
      }
    }
  }
  
+ @Transactional
+ private List<UUID> restoreDateInTransaction(LocalDate date) {
+   List<Map<String, Object>> backupDataList = findBackupsForDate(date);
+   List<Map<String, Object>> missingArticles = findMissingArticles(backupDataList);
+   
+   List<UUID> restored = new ArrayList<>();
+   for (Map<String, Object> backupData : missingArticles) {
+     Article article = restoreArticle(backupData);
+     restored.add(article.getId());
+   }
+   return restored;
+ }

옵션 2: 배치 크기 제한
복원 대상이 1000개를 초과하면 경고를 표시하거나 거부합니다.

🤖 Prompt for AI Agents
In
monew/src/main/java/com/spring/monew/article/service/impl/ArticleServiceImpl.java
around lines 184-230, the restoreArticlesFromBackup method is currently
annotated with @Transactional and runs a loop over up to 31 days which can
create a long-running transaction (locks/timeouts/huge rollback). Fix by
removing the method-level transaction and instead run each day's restore inside
its own short-lived transaction (or a bounded batch transaction): extract the
inner loop body that processes a single date into a private method annotated
with @Transactional (or programmatically start/commit a transaction per date),
limit the number of articles processed per transaction (e.g., batch size), and
add a hard cap/guard that rejects or warns when total restore candidate count
exceeds a configured threshold (e.g., 1000) before starting restores. Ensure
audit logging and result aggregation remain outside per-date transactions so
they are not rolled back with individual failures.

Comment on lines +336 to +347
private Instant parseInstant(Object value) {
if (value == null) {
return Instant.now();
}
if (value instanceof String) {
return Instant.parse((String) value);
}
if (value instanceof Number) {
return Instant.ofEpochMilli(((Number) value).longValue());
}
return Instant.now();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

타임스탬프 파싱 실패 시 Instant.now()를 사용하면 데이터가 손상됩니다.

Line 338과 346에서 값이 null이거나 파싱할 수 없을 때 Instant.now()를 반환합니다. 이는 복원된 기사의 publishDatecreatedAt가 실제 값 대신 현재 시간으로 설정되어 데이터 무결성이 손상됩니다.

백업/복구 시나리오에서는 원본 타임스탬프 보존이 중요하므로, 파싱 실패 시 예외를 발생시켜야 합니다:

  private Instant parseInstant(Object value) {
    if (value == null) {
-     return Instant.now();
+     throw new IllegalArgumentException("Timestamp value cannot be null");
    }
    if (value instanceof String) {
-     return Instant.parse((String) value);
+     try {
+       return Instant.parse((String) value);
+     } catch (Exception e) {
+       throw new IllegalArgumentException("Invalid timestamp format: " + value, e);
+     }
    }
    if (value instanceof Number) {
      return Instant.ofEpochMilli(((Number) value).longValue());
    }
-   return Instant.now();
+   throw new IllegalArgumentException("Unsupported timestamp type: " + value.getClass());
  }
🤖 Prompt for AI Agents
In
monew/src/main/java/com/spring/monew/article/service/impl/ArticleServiceImpl.java
around lines 336 to 347, do not return Instant.now() on null or failed parses
because that corrupts restored timestamps; instead validate input and throw an
unchecked exception so callers can fail the restore. Change the method to: if
value is null throw new IllegalArgumentException("timestamp value is null for
field X"); if value is a String attempt Instant.parse(...) and catch
DateTimeParseException to rethrow as IllegalArgumentException with the original
value in the message; if value is a Number convert with Instant.ofEpochMilli;
for any other type throw IllegalArgumentException describing the unsupported
type and including the actual value.

Comment on lines +67 to +103
String batchRequestId = "MANUAL-CLEANUP-" + UUID.randomUUID();
MDC.put(REQUEST_ID_KEY, batchRequestId);

try {
Instant startTime = Instant.now();
log.info("[Manual] 삭제된 게시글 정리 작업 시작 (최근 30일)");

Instant thirtyDaysAgo = Instant.now().minus(30, ChronoUnit.DAYS);
List<UUID> articleIds = articleRepository.findSoftDeletedBetween(thirtyDaysAgo, Instant.now());

if (articleIds.isEmpty()) {
log.info("[Manual] 정리할 삭제된 게시글이 없습니다.");
return new int[]{0, 0};
}

log.info("[Manual] 정리 대상 게시글 {}개 발견", articleIds.size());

int successCount = 0;
int failureCount = 0;

for (UUID articleId : articleIds) {
try {
articleService.hardDeleteArticle(articleId, SYSTEM_USER_ID);
successCount++;
} catch (Exception e) {
failureCount++;
log.error("[Manual] 게시글 {} 정리 실패: {}", articleId, e.getMessage(), e);
}
}

Duration executionTime = Duration.between(startTime, Instant.now());
log.info("[Manual] 삭제된 게시글 정리 작업 완료 - 성공: {}개, 실패: {}개, 실행 시간: {}초",
successCount, failureCount, executionTime.getSeconds());
return new int[]{successCount, failureCount};
} finally {
MDC.remove(REQUEST_ID_KEY);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

MDC requestId 복구 누락으로 요청 추적이 끊어집니다.
수동 정리 메서드는 기존 MDC requestId 값을 덮어쓴 뒤 finally 블록에서 무조건 제거하고 있습니다. 이 메서드가 HTTP 요청 컨텍스트에서 호출될 때는 필터가 심어 둔 원래 requestId가 사라져 이후 로그(예: BatchJobService의 후속 로그, 응답 처리)에서 요청 ID가 빠집니다. 감사·추적 요구사항을 충족하려면 이전 값을 저장해 두었다가 복원하고, 값이 없던 경우에만 remove 해야 합니다.

다음과 같이 수정해 주세요:

-    String batchRequestId = "MANUAL-CLEANUP-" + UUID.randomUUID();
-    MDC.put(REQUEST_ID_KEY, batchRequestId);
+    String previousRequestId = MDC.get(REQUEST_ID_KEY);
+    String batchRequestId = "MANUAL-CLEANUP-" + UUID.randomUUID();
+    MDC.put(REQUEST_ID_KEY, batchRequestId);
@@
-    } finally {
-      MDC.remove(REQUEST_ID_KEY);
-    }
+    } finally {
+      if (previousRequestId != null) {
+        MDC.put(REQUEST_ID_KEY, previousRequestId);
+      } else {
+        MDC.remove(REQUEST_ID_KEY);
+      }
+    }
🤖 Prompt for AI Agents
In
monew/src/main/java/com/spring/monew/batch/scheduler/ArticleCleanupScheduler.java
around lines 67 to 103, the method overwrites MDC requestId without preserving
the previous value and unconditionally removes it in finally, breaking tracing
for callers; fix by capturing the existing MDC.get(REQUEST_ID_KEY) into a local
variable before MDC.put(...), and in the finally block restore the previous
value (if previousValue == null then MDC.remove(REQUEST_ID_KEY) else
MDC.put(REQUEST_ID_KEY, previousValue)) so the original requestId is preserved
when present.

Comment on lines +20 to +28
@Component
@RequiredArgsConstructor
@Slf4j
public class ArticleBackupWriter implements ItemWriter<ArticleBackupDto>, StepExecutionListener {

private final S3BackupService s3BackupService;
private static final String BACKUP_COUNT_KEY = "backupCount";
private final List<ArticleBackupDto> aggregatedArticles = new ArrayList<>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

싱글톤 Writer의 상태 관리 문제로 인한 동시성 위험

@Component로 등록된 Writer는 Spring 싱글톤이지만, aggregatedArticles 인스턴스 필드는 가변 상태입니다. 수동 트리거 API와 스케줄러가 동시에 Job을 실행하거나, Job이 실패 후 재시작될 경우 다음 문제가 발생합니다:

  • 동시 실행 시 여러 Job 인스턴스가 동일한 aggregatedArticles를 동시에 수정 → 데이터 손실/오염
  • 실패 후 재시작 시 이전 실행의 잔여 데이터가 남아있어 중복 백업 가능

해결 방안:
StepExecution의 ExecutionContext에 데이터를 저장하거나, @StepScope를 사용하여 Step 실행마다 Writer 인스턴스를 새로 생성하세요.

-@Component
+@Component
+@StepScope
 @RequiredArgsConstructor
 @Slf4j
 public class ArticleBackupWriter implements ItemWriter<ArticleBackupDto>, StepExecutionListener {
 
     private final S3BackupService s3BackupService;
     private static final String BACKUP_COUNT_KEY = "backupCount";
-    private final List<ArticleBackupDto> aggregatedArticles = new ArrayList<>();
+    private List<ArticleBackupDto> aggregatedArticles = new ArrayList<>();

또는 ExecutionContext를 사용하는 방식:

// write() 메서드에서
var ec = stepExecution.getExecutionContext();
List<ArticleBackupDto> aggregated = (List<ArticleBackupDto>) ec.get("aggregatedArticles");
if (aggregated == null) {
    aggregated = new ArrayList<>();
}
aggregated.addAll(articles);
ec.put("aggregatedArticles", aggregated);
🤖 Prompt for AI Agents
In monew/src/main/java/com/spring/monew/batch/writer/ArticleBackupWriter.java
around lines 20 to 28, the writer holds mutable instance state
(aggregatedArticles) on a singleton bean which causes concurrency issues and
stale data on restarts; fix by removing the shared field and either (A) annotate
the writer with @StepScope so a new writer instance is created per step
execution and move aggregatedArticles to an instance field there, or (B) persist
per-run state in the StepExecution.getExecutionContext(): on write() read the
list from the ExecutionContext, create if null, append current items, and put it
back into the ExecutionContext; ensure you also remove/clear the key on step
completion or job restart to avoid residual data.

Copy link
Copy Markdown
Collaborator

@Namsoo315 Namsoo315 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다. 정말루요

@Namsoo315 Namsoo315 merged commit 4e3518c into codeit-monew:develop Nov 4, 2025
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] 뉴스 기사 백업 및 복구

3 participants