Skip to content

Conversation

@dltnals317
Copy link
Collaborator

@dltnals317 dltnals317 commented Sep 5, 2025

🔍️ 이 PR을 통해 해결하려는 문제가 무엇인가요?

  • closed [FEAT] Feed 테이블 정합성 관리 및 스케쥴러 처리 #214

  • 팔로우/언팔/차단/신고 흐름에서 Feed 테이블 관리 책임백필 정책이 혼재되어 있던 문제를 정리했습니다.

  • 특히, 차단/언팔 시 Feed 삭제 시점과, 재팔로우 시 전체 백필과, 증분 백필의 분기 로직을 명확히 하는 것이 목적입니다.

    • 백필(Backfill) : 과거 시점의 누락되거나 비어있는 데이터를 채우는 것
  • 상세 배경은 Notion 문서 링크 에 정리했습니다.

✨ 이 PR에서 핵심적으로 변경된 사항은 무엇일까요?

  • BlockService
    • follow / unfollow / block / unblock / report 로직을 한 곳에서 통일
    • Feed 삭제는 직접 하지 않고, 상태 변경 + 관계 정리까지만 담당하도록 정리
  • BlockStatus
    • 기존에는 차단/신고/언팔로우 상태만 관리했지만, 이제 Status에 FOLLOW 상태까지 포함하게 됨.
    • 따라서 BlockEntity는 더 이상 “차단만 관리하는 엔티티”가 아니라 Follow/Unfollow/Block/Report 전체 관계를 관리하는 공통 도메인으로 확장된 개념으로 확장
      Follow/Unfollow/Block/Report 전체 관계를 관리하는 공통 도메인 으로 확장
    • 결과적으로 “Block”은 단순히 기능을 막는 수준을 넘어서 게시글 정책과 관련된 유저 간 관계를 일관되게 기록하고 추적하는 하나의 규칙 엔티티로 진화한 것
    • 다만 네이밍 측면에서, Block이라는 이름이 직관적이긴 하지만 실제로는 책임이 과도하게 커진 상황이라,
      향후에는 별도의 엔티티로 분리하는 것도 검토할 예정
  • FeedService
    • Feed 조회 시 new_follow 기준으로 전체 백필 처리
    • Block / Report / Unfollow 상태는 조회 단계에서 필터링
  • FeedCleanupScheduler
    • Block 상태 만료 기준으로 Feed 물리 삭제(UNFOLLOWED=30일, BLOCKED=90일)
    • 삭제 후 feed_purged_at 라이트 로그 기록으로, 재팔로우 시 전체/증분 백필 판단 가능
  • UserService
    • getFollowers / getFollowings 기능 추가(삭제 되어있었어요,, 혹시 이 부분 일부러 날리신거면 알려주세요)
    • 조회 시 차단/신고 관계 유저 자동 제외
  • ZzimPostService
    • 차단/신고 유저 글은 양방향으로 보이지 않도록 필터링
  • Controller
    • UserController → follow/unfollow/block/unblock/report API를 BlockUseCase 하나로 통일

🔖 핵심 변경 사항 외에 추가적으로 변경된 부분이 있나요?

  • ReportService에서 처리하던 feed/zzim/follow 정리 책임을 BlockService.report()로 위임
  • deleteByUserIdAndAuthorId 등 중복 책임 제거
  • PR 본문에 포함하기엔 장황한 Scheduler & BlockEntity 설계 배경은 Notion 에 따로 정

🙏 Reviewer 분들이 이런 부분을 신경써서 봐 주시면 좋겠어요

  • Feed 백필 전략: 지금은 전체 백필은 조회 시점, 증분 백필은 팔로우 시점에 처리하도록 했습니다. 이게 괜찮을지?
  • feed_purged_at을 기준으로 전체/증분 분기하는 로직이 이해하기 쉽게 잘 짜였는지?
  • UserController API를 BlockUseCase 하나로 합쳤는데, 클라단에서 혼란은 없을지?

🩺 이 PR에서 테스트 혹은 검증이 필요한 부분이 있을까요?

  • Feed 백필 전략: 지금은 전체 백필은 조회 시점, 증분 백필은 팔로우 시점에 처리하도록 했습니다. 이게 괜찮을지?
  • feed_purged_at을 기준으로 전체/증분 분기하는 로직이 이해하기 쉽게 잘 짜였는지?
  • UserController API를 BlockUseCase 하나로 합쳤는데, 클라단에서 혼란은 없을지?

📌 PR 진행 시 이러한 점들을 참고해 주세요

  • Reviewer 분들은 코드 리뷰 시 좋은 코드의 방향을 제시하되, 코드 수정을 강제하지 말아 주세요.
  • Reviewer 분들은 좋은 코드를 발견한 경우, 칭찬과 격려를 아끼지 말아 주세요.
  • Review는 특수한 케이스가 아니면 Reviewer로 지정된 시점 기준으로 3일 이내에 진행해 주세요.
  • Comment 작성 시 Prefix로 P1, P2, P3 를 적어 주시면 Assignee가 보다 명확하게 Comment에 대해 대응할 수 있어요
    • P1 : 꼭 반영해 주세요 (Request Changes) - 이슈가 발생하거나 취약점이 발견되는 케이스 등
    • P2 : 반영을 적극적으로 고려해 주시면 좋을 것 같아요 (Comment)
    • P3 : 이런 방법도 있을 것 같아요~ 등의 사소한 의견입니다 (Chore)


📝 Assignee를 위한 CheckList

  • To-Do Item

Summary by CodeRabbit

  • 신규 기능

    • 사용자 신고 기능 추가.
    • 팔로워/팔로잉/차단 목록 조회 지원.
    • 재팔로우 시 최근 게시물 자동 백필로 피드 복원.
  • 개선 사항

    • 언팔/차단 시 상대와의 피드가 자동 정리되며, 매일 새벽 3시에 만료 관계를 추가 정리.
    • 즐겨찾기(찜) 목록에서 차단·상호차단·신고 관계의 사용자를 자동 제외해 노출 품질 향상.
    • 피드 중복 방지 및 생성 시간 관리로 더 안정적인 피드 제공.

@dltnals317 dltnals317 self-assigned this Sep 5, 2025
@dltnals317 dltnals317 added 🐇수민🥕 개쩌는 개발자 이수민 💡FEAT 새로운 기능 추가 labels Sep 5, 2025
@coderabbitai
Copy link

coderabbitai bot commented Sep 5, 2025

Important

Review skipped

Auto reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Walkthrough

유저 관계/차단 상태 관리를 Block 도메인 중심으로 재설계하고, Follow/Unfollow/Block/Unblock/Report 이벤트 시 Feed/Follow/NewFollow 간 정합성 처리와 재팔로우 백필 로직을 추가했다. 만료/정리 스케줄러와 페이징 처리, 만료 기준/퍼지 시각 컬럼을 도입하고, 관련 포트/어댑터/리포지토리를 확장·정비했다.

Changes

Cohort / File(s) Change Summary
Controller & API
src/.../adapter/in/web/user/UserController.java
BlockUseCase 주입; follow/unfollow/unblock/report를 BlockUseCase로 위임; POST /report 엔드포인트 추가.
Block Domain & Persistence
src/.../domain/user/Block.java, src/.../adapter/out/persistence/block/db/BlockEntity.java, src/.../adapter/out/persistence/block/BlockPersistenceAdapter.java, src/.../adapter/out/persistence/block/db/BlockRepository.java, src/.../adapter/out/persistence/block/db/BlockStatus.java, src/.../adapter/out/persistence/user/mapper/BlockMapper.java
Block에 statusChangedAt/expireAt/feedPurgedAt 추가와 상태 전이/TTL 로직; Entity에 유니크 제약 및 새 컬럼 추가; Mapper 양방향 매핑 확장; Repository에 만료 조회/퍼지 마킹 쿼리 추가; Adapter가 도메인 중심 API로 전환; FOLLOW 상태 추가.
Feed Persistence
src/.../adapter/out/persistence/feed/FeedPersistenceAdapter.java, src/.../adapter/out/persistence/feed/db/FeedEntity.java, src/.../adapter/out/persistence/feed/db/FeedRepository.java, src/.../adapter/out/persistence/feed/event/PostCreatedEventListener.java
FeedEntity에 createdAt 및 (user,post) 유니크 제약; deleteOneWay/deleteBidirectional/exists 추가; 백필(backfillIncremental) 구현 및 사용자 조회 도입; 이벤트 리스너에서 빌더 사용.
Post & User Persistence
src/.../adapter/out/persistence/post/PostPersistenceAdapter.java, src/.../adapter/out/persistence/post/db/PostRepository.java, src/.../adapter/out/persistence/user/UserPersistenceAdapter.java
작성자/시각 이후 게시글 조회 API 추가; NewFollow 관계 조회/삭제 API 추가.
Ports (in/out)
src/.../application/port/in/user/BlockUseCase.java, src/.../application/port/in/user/UserGetUseCase.java, src/.../application/port/out/feed/FeedPort.java, src/.../application/port/out/post/PostPort.java, src/.../application/port/out/user/BlockPort.java, src/.../application/port/out/user/UserPort.java
BlockUseCase 신설(상태 변경 및 5개 액션); UserGetUseCase에 팔로워/팔로잉/차단 조회 추가; FeedPort에 삭제/백필 API; PostPort에 작성자 시각 이후 조회; BlockPort를 도메인 중심으로 개편 및 만료/퍼지 API 추가; UserPort에 NewFollow 조회/삭제 추가.
Feed Services & Scheduler
src/.../application/service/feed/FeedService.java, src/.../application/service/feed/FeedCleanupScheduler.java
피드 조회 시 NewFollow 백필 및 차단/차단당함 필터 정비; 스케줄러로 만료된 차단/언팔로우에 따른 피드 정리 및 퍼지 시각 기록.
User & Report Services
src/.../application/service/user/UserService.java, src/.../application/service/report/ReportService.java
UserService에서 조회/검색/팔로워·팔로잉·차단 목록 제공 및 블록 필터 적용; ReportService에서 부수 효과 제거(신고 시 데이터 조작 제거).
Zzim Services
src/.../application/service/zzim/ZzimPostService.java
차단/신고 기반 제외 사용자 집합 통합 및 카드/포커스 리스트 필터링·매핑 리팩터링.
Domain User
src/.../domain/user/User.java
@NoArgsConstructor 및 ID 전용 생성자 추가; 일부 @Enumerated 제거.
Auth/Kakao Logging
src/.../application/auth/service/AuthService.java, src/.../application/auth/service/KakaoService.java
콘솔 출력 로그 추가(동작 영향 없음).
Feed Query Spec
src/.../adapter/out/persistence/feed/PostSpecification.java
포매팅 변경(로직 동일).

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor U as User
  participant C as UserController
  participant S as BlockService
  participant BP as BlockPort
  participant UP as UserPort
  participant FP as FeedPort
  participant PP as PostPort

  U->>C: POST /users/follow
  C->>S: follow(userId, targetUserId)
  S->>BP: findByBlockerAndBlocked
  alt no existing
    S->>BP: saveBlock(FOLLOW)
    S->>UP: saveFollow / saveNewFollow
  else existing == UNFOLLOWED
    S->>BP: saveBlock(FOLLOW with updated statusChangedAt)
    alt feedPurgedAt != null
      S->>UP: findNewFollowingIds / etc.
      S->>FP: addFeedsIfNotExists (full backfill via NewFollow)
    else statusChangedAt != null
      S->>PP: findByAuthorIdAndCreatedAtAfter
      S->>FP: backfillIncremental
    end
    S->>UP: saveFollow / deleteNewFollow
  else existing in (BLOCKED, REPORT)
    S-->>C: throw USER_NOT_FOUND
  else existing == FOLLOW
    S-->>C: throw ALREADY_FOLLOW
  end
  C-->>U: 200 OK
Loading
sequenceDiagram
  autonumber
  participant SCH as FeedCleanupScheduler
  participant BP as BlockPort
  participant FP as FeedPort

  SCH->>SCH: cron 0 0 3 * * *
  loop pages until empty
    SCH->>BP: findExpiredBlocks([UNFOLLOWED,BLOCKED], now, pageable)
    alt UNFOLLOWED
      SCH->>FP: deleteOneWay(blocker, blocked)
    else BLOCKED
      SCH->>FP: deleteBidirectional(blocker, blocked)
    else other
      SCH-->>SCH: IllegalStateException
    end
    SCH->>BP: markFeedPurgedAt(blocker, blocked, now)
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Assessment against linked issues

Objective Addressed Explanation
Block 테이블 상태 관리, 만료/퍼지 시각 처리, 정책값 주입 (#214)
Follow / Unfollow 이벤트 처리, 재팔로우 백필 및 중복 방지 (#214)
차단 / 차단 해제 / 신고 처리 및 피드 정합성 유지 (#214)
조회 로직 점검: 피드/찜 필터링 및 백필 반영 (#214)
스케쥴러 처리: BLOCKED/UNFOLLOWED 만료 물리 삭제 (#214)

Assessment against linked issues: Out-of-scope changes

Code Change Explanation
콘솔 로그 추가: System.out.println("User is null") (src/main/java/com/spoony/spoony_server/application/auth/service/AuthService.java, lines not provided) 피드 정합성/스케줄러와 무관한 인증 로깅 변경.
콘솔 로그 추가: unlink 파라미터 출력 (src/main/java/com/spoony/spoony_server/application/auth/service/KakaoService.java, lines not provided) 이슈 목적과 관련 없는 서드파티 연동 로깅.
@Enumerated 제거로 User enum 매핑 방식 변경 (src/main/java/com/spoony/spoony_server/domain/user/User.java, lines not provided) 피드 정합성 범위를 넘어 JPA 매핑에 영향. 목적에 명시되지 않음.
✨ Finishing touches
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/#214

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.


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.

@dltnals317
Copy link
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Sep 6, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@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: 28

Caution

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

⚠️ Outside diff range comments (4)
src/main/java/com/spoony/spoony_server/application/auth/service/KakaoService.java (1)

29-36: 다음 두 가지 수정이 필요합니다:

  • unlink 메소드에서 platformIdLong.parseLong으로 한 번만 파싱하고, NumberFormatException을 적절히 처리하세요.
  • System.out.println으로 민감 정보(adminKey·토큰 등)를 노출하지 말고 SLF4J Logger로 대체하세요.
src/main/java/com/spoony/spoony_server/application/service/report/ReportService.java (1)

68-73: getReportDetail() null 시 NPE 위험

trim() 호출 전에 null 가능성 처리 필요합니다. createUserReport와 createReport 모두 동일 리스크가 있습니다.

-        if (command.getReportDetail().trim().isEmpty()){
+        String detail = Optional.ofNullable(command.getReportDetail())
+                .map(String::trim)
+                .orElse("");
+        if (detail.isEmpty()){
             throw  new BusinessException(ReportErrorMessage.BAD_REQUEST_CONTENT_MISSING);
         }
-        if (command.getReportDetail().length()>300){
+        if (detail.length() > 300){
             throw new BusinessException(ReportErrorMessage.BAD_REQUEST_CONTENT_TOO_LONG);
         }
@@
-        UserReport userReport = new UserReport(userReportType, command.getReportDetail(),user,targetUser);
+        UserReport userReport = new UserReport(userReportType, detail, user, targetUser);

createReport(…)에도 동일 적용 권장:

-        if (command.getReportDetail().trim().isEmpty()) {
+        String detail = Optional.ofNullable(command.getReportDetail()).map(String::trim).orElse("");
+        if (detail.isEmpty()) {
             throw new BusinessException(ReportErrorMessage.BAD_REQUEST_CONTENT_MISSING);
         }
-        if (command.getReportDetail().length() > 300) {
+        if (detail.length() > 300) {
             throw new BusinessException(ReportErrorMessage.BAD_REQUEST_CONTENT_TOO_LONG);
         }
@@
-        Report report = new Report(reportType,command.getReportDetail(),post,user);
+        Report report = new Report(reportType, detail, post, user);
src/main/java/com/spoony/spoony_server/adapter/out/persistence/feed/event/PostCreatedEventListener.java (1)

44-56: findAllById 사용해 N+1 제거 및 중복 처리 추가

  • followerIds 배치를 한 번의 IN 쿼리(userRepository.findAllById)로 조회해 Map으로 캐시 → DB 부하·RTT 감소
  • (user_id, post_id) 유니크 제약으로 saveAll 중복 예외 발생 가능 → 배치별 try-catch로 예외 처리 후 중복 스킵/로깅 고려
src/main/java/com/spoony/spoony_server/adapter/in/web/user/UserController.java (1)

207-219: 차단도 BlockUseCase로 일원화(컨트롤러 역할 단순화)

PR 목적에 맞춰 차단 역시 BlockUseCase로 위임해 일관성을 높이는 것이 좋습니다.

-        BlockUserCommand command = new BlockUserCommand(
-                userId,
-                requestDTO.targetUserId()
-        );
-        blockUserCreateUseCase.createUserBlock(command);
+        blockUseCase.block(userId, requestDTO.targetUserId());
🧹 Nitpick comments (56)
src/main/java/com/spoony/spoony_server/application/service/zzim/ZzimPostService.java (4)

63-77: null Place를 조용히 스킵하기보다 관찰 가능성을 확보하세요

place == null을 단순 continue하면 데이터 정합성 문제를 놓칠 수 있습니다. 최소 WARN 로그 및 카운터(메트릭) 기록을 권장합니다.


134-140: 차단/신고 제외 집합 생성 시 3회 조회 → 단일 쿼리로 축약 고려

getBlockedUserIds, getBlockerUserIds, getRelatedUserIdsByReportStatus를 한 번에 반환하는 Port(예: getExcludedUserIdsForUser)를 추가하면 DB 왕복/병합 비용을 줄일 수 있습니다. 캐시(요청 스코프)도 고려하세요.


231-263: Focus DTO 매핑도 카테고리 null 계약 점검 및 대응

postCategory 또는 category가 null일 수 있는지 계약을 확인하고, null 가능 시 로그+스킵/기본값/예외 등 정책을 명시하세요. 사진 리스트가 비어도 안전하지만 카테고리 체인은 NPE 위험이 있습니다.


265-275: 지구 반지름 타입 변경(미세 개선) 및 상수화

정밀도 관점에서 double 사용이 낫습니다. 필요 시 클래스 상단에 상수로 승격하세요.

-    final int EARTH_RADIUS = 6371; // km
+    final double EARTH_RADIUS = 6371.0; // km

또는

private static final double EARTH_RADIUS_KM = 6371.0;
src/main/java/com/spoony/spoony_server/application/auth/service/AuthService.java (2)

52-55: System.out.println 대신 로거 사용 권장

표준 출력은 운영환경 로그 수집/레벨 제어가 어렵습니다. 경고 레벨 로깅으로 교체하세요.

@@
-        if (user == null) {
-            System.out.println("User is null");
-            return LoginResponseDTO.of(false, null, null);
-        }
+        if (user == null) {
+            log.warn("Login failed: user not found. platform={}", platform);
+            return LoginResponseDTO.of(false, null, null);
+        }

추가 필요:

@@
-import lombok.RequiredArgsConstructor;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
@@
-@Service
+@Service
+@Slf4j
 @RequiredArgsConstructor
 @Transactional
 public class AuthService implements

100-120: Enum 비교 시 문자열 비교 지양하고 직접 비교 사용

toString().equals("KAKAO") 패턴은 오타/리팩토링에 취약합니다. Enum 직접 비교로 단순화하세요.

-        if (userSignupDto.platform().toString().equals("KAKAO")){
+        if (userSignupDto.platform() == Platform.KAKAO){
             return kakaoService.getPlatformUserInfo(platformToken);
-        } else if (userSignupDto.platform().toString().equals("APPLE")){
+        } else if (userSignupDto.platform() == Platform.APPLE){
             return appleService.getPlatformUserInfo(platformToken);
         } else {
             throw new AuthException(AuthErrorMessage.PLATFORM_NOT_FOUND);
         }
@@
-        if (platform.toString().equals("KAKAO")){
+        if (platform == Platform.KAKAO){
             return kakaoService.getPlatformUserInfo(platformToken);
-        } else if (platform.toString().equals("APPLE")){
+        } else if (platform == Platform.APPLE){
             return appleService.getPlatformUserInfo(platformToken);
         } else {
             throw new AuthException(AuthErrorMessage.PLATFORM_NOT_FOUND);
         }
src/main/java/com/spoony/spoony_server/application/port/out/user/UserPort.java (1)

32-33: 신규 메서드 시그니처 적절 — 배치 삭제 API도 고려해주세요

단건 삭제(deleteNewFollowRelation) 반복 호출은 채팅(왕복) 비용이 큽니다. 동일 사용자에 대한 다건 삭제 배치 API 추가를 제안합니다.

제안:

+    void deleteNewFollowRelations(Long userId, List<Long> targetUserIds);

원하시면 Adapter/Repository까지 포함한 배치 구현안도 드리겠습니다.

src/main/java/com/spoony/spoony_server/application/service/report/ReportService.java (1)

1-8: 불필요한 import 정리

BlockStatus가 이 클래스에서 사용되지 않습니다. import 제거해 주세요.

-import com.spoony.spoony_server.adapter.out.persistence.block.db.BlockStatus;
src/main/java/com/spoony/spoony_server/application/port/in/user/UserGetUseCase.java (1)

13-17: 조회 API 확장 적절 — 페이징/차단 필터 명세 확인 필요

팔로워/팔로잉/차단 목록은 대량 데이터가 될 수 있습니다. DTO가 페이지네이션/커서 정보를 포함하는지, 그리고 차단/신고 사용자가 결과에서 일관되게 제외되는지 확인 부탁드립니다.

필요 시:

  • FollowListResponseDTO/BlockListResponseDTO에 cursor/hasNext 등 추가
  • 정렬 기준(createdAt/statusChangedAt 등) 명시
src/main/java/com/spoony/spoony_server/adapter/out/persistence/feed/event/PostCreatedEventListener.java (3)

40-43: 매직 넘버(10_000) 외부화

배치 크기 10,000은 운영 환경/DB 커넥션 상황에 따라 조정 필요합니다. 설정값(@value, application.yml)으로 외부화해 동적으로 튜닝 가능하게 해주세요.


30-31: System.out 대신 로거 사용

운영 로그는 Logger(예: lombok @slf4j 또는 LoggerFactory)를 사용해 주세요. 비동기 이벤트 리스너에서는 특히 중요합니다.


1-1: 코딩 컨벤션 일회성 안내

현재 들여쓰기에 스페이스가 사용됩니다. 사내 규칙(탭 들여쓰기, indent_size=4, 최대 120자, K&R 스타일)에 맞는지 한 번 점검 부탁드립니다.

src/main/java/com/spoony/spoony_server/application/port/out/post/PostPort.java (1)

49-50: 메서드 명명 일관성(Author/User/Posts 접두사 정렬)

기존 포트는 findPostsByUserId처럼 Posts를 명시합니다. 새 메서드도 가독성/일관성을 위해 다음과 같이 변경을 제안합니다.

-    List<Post> findByAuthorIdAndCreatedAtAfter(Long authorId, LocalDateTime since);
+    List<Post> findPostsByAuthorIdAndCreatedAtAfter(Long authorId, LocalDateTime since);

또한 레포지토리는 UserId 용어를 쓰므로, 포트/어댑터/레포지토리 전반의 용어(Author vs User) 통일도 검토해 주세요.

src/main/java/com/spoony/spoony_server/adapter/out/persistence/post/db/PostRepository.java (1)

10-14: 보조 인덱스 제안: (user_id, created_at, post_id)

위 쿼리를 고빈도로 사용할 예정이라면 다음 복합 인덱스를 추가해 Range Scan 성능을 확보해 주세요.

예시 DDL

CREATE INDEX idx_post_user_created_at_post_id
ON post(user_id, created_at, post_id);
src/main/java/com/spoony/spoony_server/application/port/out/feed/FeedPort.java (1)

15-19: 중복/혼동 가능 API 정리(Deprecate + 시그니처 정돈)

deleteByUserIdAndAuthorIddeleteOneWay/ deleteBidirectional의 역할이 겹칩니다. 혼선을 줄이려면 deprecated 처리 또는 주석으로 명확히 분리해 주세요. 또한 targetUserId/authorId 용어 통일을 권장합니다.

-    void deleteByUserIdAndAuthorId(Long userId, Long authorId);
+    @Deprecated // 사용처는 deleteOneWay(언팔) 또는 deleteBidirectional(차단/신고)로 전환 권장
+    void deleteByUserIdAndAuthorId(Long userId, Long authorId);

-    void backfillIncremental(Long userId, Long targetUserId, List<Post> newPosts);
+    void backfillIncremental(Long userId, Long authorId, List<Post> newPosts);
src/main/java/com/spoony/spoony_server/adapter/out/persistence/feed/PostSpecification.java (6)

97-103: 불필요한 리스트 초기화 및 조기 반환으로 단순화 가능

ageGroups가 null/empty면 바로 cb.conjunction() 반환하면 리스트 생성 불필요합니다. 성능 영향은 경미하나 가독성 향상됩니다.

-        return (root, query, cb) -> {
-            List<Predicate> predicates = new ArrayList<>();
-            logger.debug("연령대 필터링 시작: ageGroups = {}", ageGroups);
-
-            if (ageGroups != null && !ageGroups.isEmpty()) {
+        return (root, query, cb) -> {
+            logger.debug("연령대 필터링 시작: ageGroups = {}", ageGroups);
+            if (ageGroups == null || ageGroups.isEmpty()) {
+                return cb.conjunction();
+            }
+            List<Predicate> predicates = new ArrayList<>();
                 // UserEntity의 ageGroup과 비교하여 필터링
                 Join<PostEntity, UserEntity> userJoin = root.join("user");
                 predicates.add(userJoin.get("ageGroup").in(ageGroups));
                 logger.debug("ageGroup in {} 필터링 추가", ageGroups);
-
-            }
             logger.debug("연령대 필터링 최종 Predicate: {}", predicates);
             return cb.and(predicates.toArray(new Predicate[0]));
         };

115-117: 파라미터 의미를 주석으로 더 명확히 하거나 DTO로 묶기

blockedUserIds(내가 차단/신고한), blockerUserIds(나를 차단/신고한) 혼동 여지가 있습니다. 명확한 명명 또는 DTO로 그룹화하면 오용 위험이 줄어듭니다.


153-155: 커서 조건 중복 정의

zzimCount/createdAt 커서 조건이 withCursor와 buildFilterSpec 하단에 중복 구현되어 있습니다. 한쪽으로 일원화하면 유지보수성 향상됩니다.

-            return (root, query, cb) -> {
-                Predicate zzimLess = cb.lessThan(root.get("zzimCount"), cursor.zzimCount());
-                Predicate zzimEqualCreatedAtLess = cb.and(
-                    cb.equal(root.get("zzimCount"), cursor.zzimCount()),
-                    cb.lessThan(root.get("createdAt"), cursor.createdAt())
-                );
-                return cb.or(zzimLess, zzimEqualCreatedAtLess);
-            };
+            return (root, query, cb) -> PostSpecification.buildZzimCursorPredicate(root, cb, cursor);
...
-            return (root, query, cb) ->
-                cb.lessThan(root.get("createdAt"), cursor.createdAt());
+            return (root, query, cb) -> cb.lessThan(root.get("createdAt"), cursor.createdAt());

(별도 정적 헬퍼 buildZzimCursorPredicate 추가 제안)

Also applies to: 162-162


166-175: 빌더 메서드 시그니처가 비대함

필터 파라미터가 많아 가독성이 떨어집니다. 요청 DTO(예: PostFilterCriteria)를 도입하면 확장성과 테스트 용이성이 좋아집니다.


181-198: Specification 결합 시 null 처리 패턴 단순화 가능

(spec != null ? spec : Specification.where(null)) 반복 대신 유틸 메서드로 흡수하면 깔끔합니다.

-        Specification<PostEntity> baseSpec = Specification.where(localReviewSpec)
-            .and(regionSpec)
-            .and(ageGroupSpec != null ? ageGroupSpec : Specification.where(null))
-            .and(categorySpec != null ? categorySpec : Specification.where(null))
-            .and(exclusionSpec != null ? exclusionSpec : Specification.where(null))
-            .and(cursorSpec != null ? cursorSpec : Specification.where(null));
+        Specification<PostEntity> baseSpec = Specification.where(localReviewSpec)
+            .and(regionSpec)
+            .and(opt(ageGroupSpec))
+            .and(opt(categorySpec))
+            .and(opt(exclusionSpec))
+            .and(opt(cursorSpec));

(정적 메서드 opt(Specification<T> s)가 null일 때 Specification.where(null) 반환)


229-229: 코드 컨벤션(탭 인덴트) 경고

Java 파일은 hackday-conventions-java 기준 탭 인덴트, K&R 스타일 권장입니다. 현재 스페이스 인덴트가 혼재합니다. 일회성 체크만 남깁니다.

src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/UserPersistenceAdapter.java (2)

261-263: 성능/인덱스 고려: findFollowedUserIdsByUserId

대량 유저에서 빈번히 호출되면 인덱스((new_follower_id) 또는 (user_id, following_id))가 필요합니다. 페이지네이션이 없다면 호출부에서 배치 처리도 고려하세요.


255-258: deleteNewFollowRelation 호출 대상인 deleteFollowRelation이 NewFollowRepository에 @Modifying@query로 올바르게 정의되어 있음. 영향 행 수 확인·로깅이 필요할 경우 반환 타입을 int로 변경하고 로직을 추가하세요.

src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/mapper/BlockMapper.java (1)

24-35: toEntity에서 User 매핑 전략 최적화 제안

UserMapper.toEntity()가 전체 사용자 엔티티를 구성/로드한다면 불필요한 로딩/병합이 발생할 수 있습니다. 식별자 참조만 설정하는 경량 매퍼를 권장합니다.

-        return BlockEntity.builder()
-            .blockId(block.getBlockId())
-            .blocker(UserMapper.toEntity(block.getBlocker()))
-            .blocked(UserMapper.toEntity(block.getBlocked()))
+        return BlockEntity.builder()
+            .blockId(block.getBlockId())
+            // 성능 최적화를 위해 ID-only 엔티티/프록시 사용 권장
+            .blocker(UserMapper.toEntityIdOnly(block.getBlocker()))
+            .blocked(UserMapper.toEntityIdOnly(block.getBlocked()))
             .status(block.getStatus())
             .statusChangedAt(block.getStatusChangedAt())
             .expireAt(block.getExpireAt())
             .feedPurgedAt(block.getFeedPurgedAt())
             .build();

UserMapper.toEntityIdOnly(User)와 같이 식별자만 매핑하는 헬퍼 추가를 제안합니다.

src/main/java/com/spoony/spoony_server/adapter/out/persistence/feed/db/FeedRepository.java (4)

30-35: 양방향 벌크 삭제에도 동일한 flush/clear 적용 권장

동일 사유로 clear/flush 자동화를 권장합니다.

-    @Modifying
+    @Modifying(clearAutomatically = true, flushAutomatically = true)
     @Query("""
       delete from FeedEntity f
       where (f.user.userId = :u and f.author.userId = :t)
          or (f.user.userId = :t and f.author.userId = :u)
     """)
     int deleteBidirectional(@Param("u") Long u, @Param("t") Long t);

14-15: 기능 중복 제거: 메서드 하나로 통일

deleteByUser_UserIdAndAuthor_UserId(...)deleteOneWay(...)가 동일 목적(단방향 삭제)입니다. 유지보수성을 위해 하나로 통일하거나 한쪽을 @deprecated 처리하세요.

예) 파생 메서드만 사용:

-    @Modifying(clearAutomatically = true, flushAutomatically = true)
-    @Query("""
-       delete from FeedEntity f 
-       where f.user.userId =:u and f.author.userId = :t   
-        """)
-    int deleteOneWay(@Param("u") Long u, @Param("t") Long t);
+    void deleteByUser_UserIdAndAuthor_UserId(Long userId, Long authorId);

또는 JPQL 메서드만 유지 시, 파생 메서드 제거.

Also applies to: 21-26


19-19: 주석 오탈자

UNFOLLOEUNFOLLOW로 수정 바랍니다.

-    //Status == UNFOLLOE인 경우 -> 단방향 삭제
+    // Status == UNFOLLOW 인 경우 -> 단방향 삭제

21-26: JPQL 벌크 삭제 후 1차 캐시 정합성 확보를 위해 @Modifying 옵션 추가 권장
FeedRepository의 deleteOneWay/deleteBidirectional 메서드는 현재 @Modifying만 적용되어 있어, 영속성 컨텍스트에 남아 있는 스테일 엔티티가 후속 로직에 노출될 수 있습니다. FeedService·FeedCleanupScheduler에는 @transactional이 적용되어 있지만, BlockService에는 별도 트랜잭션이 없어 리포지토리 단에서 안전장치로 flush/clear 옵션을 지정하는 것이 좋습니다. 아래와 같이 수정하세요.

-    @Modifying
+    @Modifying(clearAutomatically = true, flushAutomatically = true)
src/main/java/com/spoony/spoony_server/adapter/in/web/user/UserController.java (4)

191-198: unfollow 대칭성 확인 필요

언팔로우는 blockUseCase.unfollow(...)만 호출합니다. 팔로우 관계 삭제가 BlockUseCase 내부에서 수행되는지 확인이 필요합니다. follow에서 createFollow를 별도로 호출하는 현재 구조와 비대칭입니다.

  • BlockUseCase가 언팔로우 영속 삭제까지 포함한다면 현 구조 유지.
  • 아니라면 userFollowUseCase의 언팔로우 삭제를 추가하세요.

Also applies to: 195-197


186-194: DELETE 메서드의 RequestBody 사용 재고(호환성/캐시 관점)

일부 클라이언트/프록시는 DELETE 본문을 지원하지 않습니다. URI 경로 파라미터로 식별자를 받도록 변경을 고려해 주세요.

-    @DeleteMapping("/follow")
-    public ResponseEntity<ResponseDTO<Void>> unfollowUser(@UserId Long userId, @RequestBody UserFollowRequestDTO requestDTO) {
-        blockUseCase.unfollow(userId, requestDTO.targetUserId());
+    @DeleteMapping("/follow/{targetUserId}")
+    public ResponseEntity<ResponseDTO<Void>> unfollowUser(@UserId Long userId, @PathVariable Long targetUserId) {
+        blockUseCase.unfollow(userId, targetUserId);
         return ResponseEntity.ok(ResponseDTO.success(null));
     }
-    @DeleteMapping("/block")
-    public ResponseEntity<ResponseDTO<Void>> unBlockUser(@UserId Long userId, @RequestBody UserBlockRequestDTO requestDTO) {
-        blockUseCase.unblock(userId, requestDTO.targetUserId());
+    @DeleteMapping("/block/{targetUserId}")
+    public ResponseEntity<ResponseDTO<Void>> unBlockUser(@UserId Long userId, @PathVariable Long targetUserId) {
+        blockUseCase.unblock(userId, targetUserId);
         return ResponseEntity.ok(ResponseDTO.success(null));
     }

Also applies to: 226-231


236-244: 메서드명과 기능 불일치

searchLocations는 유저 검색을 수행합니다. 혼동 방지를 위해 메서드명을 searchUsers 등으로 정정하세요.

-    public ResponseEntity<ResponseDTO<UserSearchResponseListDTO>> searchLocations(
+    public ResponseEntity<ResponseDTO<UserSearchResponseListDTO>> searchUsers(

24-27: 리소스 경로 네이밍(참고)

베이스 경로가 단수(/api/v1/user)입니다. 신규 API 확장성을 고려하면 관례적으로 복수형(/api/v1/users)을 권장합니다. 현 변경 범위 밖이므로 참고만 남깁니다.

src/main/java/com/spoony/spoony_server/application/port/in/user/BlockUseCase.java (1)

5-12: 파라미터 네이밍 일관성

changeStatus(blockerId, blockedId) vs 나머지 userId, targetUserId가 혼재합니다. API 사용성 향상을 위해 일관된 명명으로 정렬하세요.

예) 모두 userId, targetUserId 사용.

src/main/java/com/spoony/spoony_server/domain/user/User.java (3)

3-4: JPA 관련 import 제거 권고

도메인 객체(User)는 @entity가 아니므로 jakarta.persistence.EnumType/Enumerated import는 혼란만 유발합니다. 제거해 주세요.

-import jakarta.persistence.EnumType;
-import jakarta.persistence.Enumerated;

14-15: 무분별한 public 무인자 생성자 지양 — 불변성/유효성 깨질 수 있음

프레임워크 요구(예: Jackson) 때문에 필요하다면 접근 제어를 제한하세요. PROTECTED로 두고, 팩토리/빌더 사용을 권장합니다.

+import lombok.AccessLevel;
-@NoArgsConstructor
+@NoArgsConstructor(access = AccessLevel.PROTECTED)

확인: 이 클래스의 public no-args가 실제로 필요한 사용처가 있나요?

Also applies to: 7-7


1-33: 스타일 일회성 안내: 들여쓰기

현재 공백 4칸 사용. 가이드라인은 탭(indent_style=tab, size=4)을 권장합니다. 추후 포맷터 설정으로 일관화 부탁드립니다.

src/main/java/com/spoony/spoony_server/adapter/out/persistence/feed/FeedPersistenceAdapter.java (3)

55-60: 중복 매핑 및 불필요 객체 생성

PostMapper.toEntity(post)를 두 번 호출합니다. 한 번만 생성해 재사용하세요. 또한 author는 postEntity.getUser()로 일관되게 가져오면 매핑 일치성이 좋아집니다.

-                .map(post -> FeedEntity.builder()
-                        .user(UserMapper.toEntity(user))
-                        .post(PostMapper.toEntity(post))
-                        .author(PostMapper.toEntity(post).getUser()) // 또는 Post에서 작성자 유저 꺼내기
-                        .build())
+                .map(post -> {
+                        var postEntity = PostMapper.toEntity(post);
+                        return FeedEntity.builder()
+                            .user(UserMapper.toEntity(user))
+                            .author(postEntity.getUser())
+                            .post(postEntity)
+                            .build();
+                })

85-85: 백필 중 user 미존재 시 예외 대신 무시 고려

스케줄/비동기 플로우에서 USER_NOT_FOUND로 전체 작업이 중단될 수 있습니다. 로깅 후 스킵하는 것이 운영 안정성에 유리합니다.


71-79: 대량 삭제 경로에 인덱스 필요

deleteOneWay/deleteBidirectional는 (user_id, author_id) 조건 삭제일 가능성이 큽니다. 다음 인덱스 권장: feed(user_id, author_id), 또한 기존 유니크(user_id, post_id)는 유지. 레포지토리 @Modifying, 배치 옵션(clearAutomatically)도 확인 바랍니다.

src/main/java/com/spoony/spoony_server/application/service/feed/FeedCleanupScheduler.java (2)

50-73: 개별 실패 격리 및 관측성 보강

한 건 실패로 전체 배치가 중단되지 않도록 try/catch로 격리하고, 삭제 건수 로깅/메트릭을 추가하세요. 또한 중복 실행 방지를 위해 ShedLock 등 분산 락을 고려하세요.

-            for (Block block : blocks) {
-                switch (block.getStatus()) {
+            for (Block block : blocks) {
+                try {
+                    switch (block.getStatus()) {
                     case UNFOLLOWED -> {
                         // UNFOLLOWED → 단방향 삭제
                         feedPort.deleteOneWay(block.getBlocker().getUserId(),
                             block.getBlocked().getUserId());
                     }
                     case BLOCKED -> {
                         // BLOCKED → 양방향 삭제
                         feedPort.deleteBidirectional(block.getBlocker().getUserId(),
                             block.getBlocked().getUserId());
                     }
                     default -> throw new IllegalStateException(
                         "스케줄러 대상이 아닌 상태 발견: " + block.getStatus()
                     );
-                }
+                    }
 
                 // feed 삭제 시각 기록 → feed_purged_at 업데이트
                 blockPort.markFeedPurgedAt(
                     block.getBlocker().getUserId(),
                     block.getBlocked().getUserId(),
                     now
                 );
+                } catch (Exception e) {
+                    // TODO: 로깅/메트릭 (blocker, blocked, status, 에러메시지)
+                }
             }

31-33: 설정 키/타임존 명시

@value("${batch.size:2000}")는 범용 키로 충돌 우려가 있습니다. feed 관련 네임스페이스로 변경을 권장합니다. 스케줄 타임존도 명시하세요.

-    @Value("${batch.size:2000}") int pageSize;
-    @Scheduled(cron = "0 0 3 * * *")
+    @Value("${feed.cleanup.page-size:2000}") int pageSize;
+    @Scheduled(cron = "0 0 3 * * *", zone = "${app.timezone:Asia/Seoul}")
src/main/java/com/spoony/spoony_server/adapter/out/persistence/feed/db/FeedEntity.java (2)

23-33: 연관관계 null 방지 명시

JPA 측면에서 optional=false를 추가해 제약을 명확히 하세요. (DDL은 이미 nullable=false)

-    @ManyToOne(fetch = FetchType.LAZY)
+    @ManyToOne(fetch = FetchType.LAZY, optional = false)
     @JoinColumn(name = "user_id", nullable = false)
 ...
-    @ManyToOne(fetch = FetchType.LAZY)
+    @ManyToOne(fetch = FetchType.LAZY, optional = false)
     @JoinColumn(name = "author_id", nullable = false)
 ...
-    @ManyToOne(fetch = FetchType.LAZY)
+    @ManyToOne(fetch = FetchType.LAZY, optional = false)
     @JoinColumn(name = "post_id", nullable = false)

16-18: 유니크 제약 추가는 적절함

(user_id, post_id) 유니크로 중복 피드 방지됩니다. 대량 삭제 최적화를 위해 (user_id, author_id) 보조 인덱스도 고려 바랍니다.

src/main/java/com/spoony/spoony_server/application/port/out/user/BlockPort.java (1)

16-24: 레거시 API 혼재 — 인터페이스 표면 축소 필요

deleteUserBlockRelation/existsBlockUserRelation 등 관계 중심 메서드는 새 도메인 API와 중복됩니다. 일단 @deprecated로 표시하고, 사용처 제거 후 인터페이스에서 제거를 제안합니다.

-    void deleteUserBlockRelation(Long fromUserId, Long toUserId, BlockStatus status);
-    boolean existsBlockUserRelation(Long fromUserId, Long toUserId);
+    @Deprecated void deleteUserBlockRelation(Long fromUserId, Long toUserId, BlockStatus status);
+    @Deprecated boolean existsBlockUserRelation(Long fromUserId, Long toUserId);

또한 findExpiredBlocks의 필터 조건(예: feedPurgedAt IS NULL, expireAt <= now)을 자바독로 명시해 오해를 줄이세요.

src/main/java/com/spoony/spoony_server/application/service/feed/FeedService.java (1)

66-70: 필터 성능(Set 사용) + ‘신고(Report)’ 상태 필터링 누락

  • 현재 List.contains로 매 피드마다 O(n) 탐색 → Set으로 전환 권장.
  • PR 요약엔 “Block/Report/Unfollow 상태 필터링”이라 했으나 본 메서드는 Report 필터가 없습니다. 비즈 규칙상 신고 사용자/게시물 배제 필요하면 동일 단계에서 반영해야 합니다.

성능 개선 diff(Set 적용):

-        List<Long> blockedUserIds  = blockPort.getBlockedUserIds(command.getUserId());
+        java.util.Set<Long> blockedUserIds  = new java.util.HashSet<>(blockPort.getBlockedUserIds(command.getUserId()));
-        List<Long> blockerUserIds  = blockPort.getBlockerUserIds(command.getUserId());
+        java.util.Set<Long> blockerUserIds  = new java.util.HashSet<>(blockPort.getBlockerUserIds(command.getUserId()));

추가로, 필요 시 신고 관계도 미리 조회해 동일 필터 체인에서 제외하세요.

Also applies to: 75-78

src/main/java/com/spoony/spoony_server/domain/user/Block.java (2)

41-49: id 전용 User 생성 방식 확인 필요

new User(blockerId)/new User(blockedId) 생성자가 실제로 존재/유지되는지 확인해 주세요. 명시적 팩토리(예: User.idOnly(id) 또는 User.of(id))를 두면 의도 전달이 더 명확하고 생성자 변경에 덜 취약합니다. 필요 시 해당 정적 팩토리 추가를 제안합니다.


66-69: feedPurgedAt는 단조 증가(idempotent) 보장 권장

재실행/중복 호출 시 이전 기록을 덮지 않도록 가드가 있으면 안전합니다.

-    public void markFeedPurged(LocalDateTime now) {
-        this.feedPurgedAt = now;
-    }
+    public void markFeedPurged(LocalDateTime now) {
+        if (this.feedPurgedAt == null || now.isAfter(this.feedPurgedAt)) {
+            this.feedPurgedAt = now;
+        }
+    }
src/main/java/com/spoony/spoony_server/adapter/out/persistence/block/db/BlockEntity.java (1)

38-42: 만료 스케줄러 쿼리 성능을 위한 인덱스 추가 제안

findExpiredBlocks(status, expireAt <= now) 류의 쿼리를 고려하면 (status, expire_at) 복합 인덱스가 효과적입니다. blocker_id/blocked_id 단독 인덱스도 조회 최적화에 도움.

제안 diff(@table에 인덱스 추가):

-@Table(name = "block",
-    uniqueConstraints = @UniqueConstraint(columnNames = {"blocker_id", "blocked_id"}))
+@Table(
+    name = "block",
+    uniqueConstraints = @UniqueConstraint(columnNames = {"blocker_id", "blocked_id"}),
+    indexes = {
+        @Index(name = "idx_block_status_expire_at", columnList = "status, expire_at"),
+        @Index(name = "idx_block_blocker_id", columnList = "blocker_id"),
+        @Index(name = "idx_block_blocked_id", columnList = "blocked_id")
+    }
+)
src/main/java/com/spoony/spoony_server/application/service/Block/BlockService.java (3)

1-1: 패키지명은 소문자 권장 (hackday-conventions-java).

package ...service.Block; 처럼 대문자를 포함한 패키지명은 지양하고, ...service.block처럼 소문자 패키지를 권장합니다.


129-140: report 처리: 삭제 호출의 멱등성/에러 핸들링.

상대 방향까지 일괄 삭제하는 부분이 재시도에 안전한지(존재하지 않아도 성공)와 예외 전파 정책을 확인해주세요. 포트 레벨에서 “있으면 삭제” 형태로 멱등하게 구현 권장.


143-150: 상태 변경의 경쟁 조건과 재시도 안전성.

findByBlockerAndBlocked -> updateStatus -> save 흐름은 경합 시 덮어쓰기 위험. BlockEntity에 version(낙관적 락) 추가 및 저장 실패 시 재시도 전략을 권장합니다.

src/main/java/com/spoony/spoony_server/application/service/user/UserService.java (2)

134-151: Followers 조회: 블록 필터 contains 비용과 팔로우 여부 N+1.

위와 동일하게 Set 변환 및 팔로우 여부 일괄 조회로 교체 권장. 결과 집계(count)는 컬렉션 크기 의존이므로 OK.

-        List<Long> blockedUserIds = blockPort.getBlockedUserIds(currentUserId);
-        List<Long> blockerUserIds = blockPort.getBlockerUserIds(currentUserId);
+        Set<Long> blockedUserIds = new HashSet<>(blockPort.getBlockedUserIds(currentUserId));
+        Set<Long> blockerUserIds = new HashSet<>(blockPort.getBlockerUserIds(currentUserId));

159-176: Followings 조회: 동일한 최적화 적용.

Set 변환 + 팔로우 관계 일괄 조회 포트 도입 권장.

-        List<Long> blockedUserIds = blockPort.getBlockedUserIds(currentUserId);
-        List<Long> blockerUserIds = blockPort.getBlockerUserIds(currentUserId);
+        Set<Long> blockedUserIds = new HashSet<>(blockPort.getBlockedUserIds(currentUserId));
+        Set<Long> blockerUserIds = new HashSet<>(blockPort.getBlockerUserIds(currentUserId));
src/main/java/com/spoony/spoony_server/adapter/out/persistence/block/BlockPersistenceAdapter.java (3)

42-45: 만료 블록 조회 read 전용 힌트.

대량 조회일 가능성이 높아 메서드 레벨 @Transactional(readOnly = true) 부여를 고려하세요(클래스 레벨 트랜잭션이 쓰기 기본인 경우).


87-181: 대규모 주석 처리(dead code) 정리 필요.

과거 구현 전체가 주석으로 남아 있어 가독성/유지보수에 악영향. 히스토리는 Git에 있으니 제거 권장.

-    // private final UserRepository userRepository;
-    // private final BlockRepository blockRepository;
-    // ...
-    // public void markFeedPurgedAt(Long blockerId, Long blockedId, LocalDateTime now) {
-    //     blockRepository.markFeedPurgedAt(blockerId,blockedId,now);
-    // }
+    // (주석 제거)

53-60: 상태별 삭제 메서드의 호출부 일치 여부 확인.

deleteUserBlockRelation(..., status)는 현재 서비스 계층에서 사용되지 않는 것으로 보입니다. 불필요 시 포트/어댑터에서 제거하여 API 표면 축소를 검토하세요.

📜 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 b39b591 and 06c9771.

📒 Files selected for processing (30)
  • src/main/java/com/spoony/spoony_server/adapter/in/web/user/UserController.java (5 hunks)
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/block/BlockPersistenceAdapter.java (1 hunks)
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/block/db/BlockEntity.java (1 hunks)
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/block/db/BlockRepository.java (2 hunks)
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/block/db/BlockStatus.java (1 hunks)
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/feed/FeedPersistenceAdapter.java (3 hunks)
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/feed/PostSpecification.java (8 hunks)
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/feed/db/FeedEntity.java (2 hunks)
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/feed/db/FeedRepository.java (2 hunks)
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/feed/event/PostCreatedEventListener.java (1 hunks)
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/post/PostPersistenceAdapter.java (2 hunks)
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/post/db/PostRepository.java (2 hunks)
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/UserPersistenceAdapter.java (1 hunks)
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/mapper/BlockMapper.java (1 hunks)
  • src/main/java/com/spoony/spoony_server/application/auth/service/AuthService.java (1 hunks)
  • src/main/java/com/spoony/spoony_server/application/auth/service/KakaoService.java (1 hunks)
  • src/main/java/com/spoony/spoony_server/application/port/in/user/BlockUseCase.java (1 hunks)
  • src/main/java/com/spoony/spoony_server/application/port/in/user/UserGetUseCase.java (1 hunks)
  • src/main/java/com/spoony/spoony_server/application/port/out/feed/FeedPort.java (1 hunks)
  • src/main/java/com/spoony/spoony_server/application/port/out/post/PostPort.java (2 hunks)
  • src/main/java/com/spoony/spoony_server/application/port/out/user/BlockPort.java (1 hunks)
  • src/main/java/com/spoony/spoony_server/application/port/out/user/UserPort.java (1 hunks)
  • src/main/java/com/spoony/spoony_server/application/service/Block/BlockService.java (1 hunks)
  • src/main/java/com/spoony/spoony_server/application/service/feed/FeedCleanupScheduler.java (1 hunks)
  • src/main/java/com/spoony/spoony_server/application/service/feed/FeedService.java (3 hunks)
  • src/main/java/com/spoony/spoony_server/application/service/report/ReportService.java (2 hunks)
  • src/main/java/com/spoony/spoony_server/application/service/user/UserService.java (1 hunks)
  • src/main/java/com/spoony/spoony_server/application/service/zzim/ZzimPostService.java (5 hunks)
  • src/main/java/com/spoony/spoony_server/domain/user/Block.java (1 hunks)
  • src/main/java/com/spoony/spoony_server/domain/user/User.java (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.java

⚙️ CodeRabbit configuration file

**/*.java: 다음 규칙은 '일회성 체크'로 위반 패턴만 지적(지속적 강제 X). hackday-conventions-java 기준: - 탭 들여쓰기(indent_style=tab), indent_size=4 - 한 줄 최대 120자 (max_line_length=120) - 파일 끝 개행 (newline-eof) - 주석 전후 공백 (space-around-comment) - 중괄호 K&R 스타일 참고: https://naver.github.io/hackday-conventions-java/

Files:

  • src/main/java/com/spoony/spoony_server/application/port/out/user/UserPort.java
  • src/main/java/com/spoony/spoony_server/application/auth/service/AuthService.java
  • src/main/java/com/spoony/spoony_server/application/port/in/user/BlockUseCase.java
  • src/main/java/com/spoony/spoony_server/application/port/out/feed/FeedPort.java
  • src/main/java/com/spoony/spoony_server/application/service/report/ReportService.java
  • src/main/java/com/spoony/spoony_server/application/port/out/post/PostPort.java
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/post/PostPersistenceAdapter.java
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/post/db/PostRepository.java
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/feed/event/PostCreatedEventListener.java
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/block/db/BlockStatus.java
  • src/main/java/com/spoony/spoony_server/application/port/in/user/UserGetUseCase.java
  • src/main/java/com/spoony/spoony_server/application/port/out/user/BlockPort.java
  • src/main/java/com/spoony/spoony_server/adapter/in/web/user/UserController.java
  • src/main/java/com/spoony/spoony_server/application/service/feed/FeedCleanupScheduler.java
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/UserPersistenceAdapter.java
  • src/main/java/com/spoony/spoony_server/application/auth/service/KakaoService.java
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/feed/db/FeedRepository.java
  • src/main/java/com/spoony/spoony_server/domain/user/Block.java
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/block/db/BlockEntity.java
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/mapper/BlockMapper.java
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/block/db/BlockRepository.java
  • src/main/java/com/spoony/spoony_server/application/service/Block/BlockService.java
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/feed/FeedPersistenceAdapter.java
  • src/main/java/com/spoony/spoony_server/application/service/feed/FeedService.java
  • src/main/java/com/spoony/spoony_server/domain/user/User.java
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/feed/db/FeedEntity.java
  • src/main/java/com/spoony/spoony_server/application/service/user/UserService.java
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/feed/PostSpecification.java
  • src/main/java/com/spoony/spoony_server/application/service/zzim/ZzimPostService.java
  • src/main/java/com/spoony/spoony_server/adapter/out/persistence/block/BlockPersistenceAdapter.java
**/*Controller*.java

⚙️ CodeRabbit configuration file

**/*Controller*.java: REST 컨트롤러 체크: - @RestController 명시 - 리소스 중심 URI(/api/v1/users 등) - 동작은 서브리소스/액션으로 표현(/orders/{id}/cancel) - 하나의 URI에 하나의 식별자 - HTTP 메서드 의미에 맞게 사용(GET/POST/PUT/DELETE)

Files:

  • src/main/java/com/spoony/spoony_server/adapter/in/web/user/UserController.java
🔇 Additional comments (20)
src/main/java/com/spoony/spoony_server/application/service/report/ReportService.java (1)

75-77: Optional 기반 기본값 처리 깔끔합니다

null일 때 PROMOTIONAL_CONTENT 기본값 부여 방식 명확합니다. 기존 createReport의 기본값 처리와 일관성 있습니다.

src/main/java/com/spoony/spoony_server/adapter/out/persistence/block/db/BlockStatus.java (1)

3-8: BlockEntity.status에 STRING 매핑 적용으로 ORDINAL 위험 없음
BlockEntity.status 필드에 이미 @Enumerated(EnumType.STRING)이 적용되어 있어 enum 순서 변경 시에도 기존 데이터가 올바르게 해석됩니다. 별도 마이그레이션이나 추가 조치 불필요합니다.

src/main/java/com/spoony/spoony_server/application/port/out/post/PostPort.java (1)

11-11: 시간 파라미터의 기준/타임존 명시 필요

LocalDateTime since는 타임존 정보가 없어 서버/DB 타임존 차이로 경계값 오차가 날 수 있습니다. 저장 측이 UTC라면 Instant/OffsetDateTime 또는 명시적 TZ 합의를 문서화해 주세요.

src/main/java/com/spoony/spoony_server/adapter/out/persistence/post/PostPersistenceAdapter.java (1)

330-336: 경계 포함 여부 명세

CreatedAtAfter는 “초과(>)”입니다. 동일 타임스탬프 경계에서 누락/중복 방지를 위해 커서 기준(시각, postId)을 함께 관리하거나, 포함(≥)일 경우 명세/테스트를 맞춰 주세요.

src/main/java/com/spoony/spoony_server/application/port/out/feed/FeedPort.java (1)

18-18: 증분 백필 계약 정의(Idempotent/정렬/용량)

newPosts에 대해 (1) 정렬 기준(asc by createdAt, postId) (2) 중복 방지(idempotent) (3) 대용량 시 배치/스트리밍 계약을 포트 주석으로 명시해 주세요. 구현은 유니크키와 존재검사로 보완하세요.

src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/mapper/BlockMapper.java (1)

11-21: toDomain 필드 매핑은 타당해 보임

엔티티의 신규 타임스탬프 필드를 도메인으로 모두 반영하고 있어 일관성이 있습니다.

src/main/java/com/spoony/spoony_server/adapter/in/web/user/UserController.java (2)

226-231: 차단 해제 흐름 일원화는 좋습니다

unblock을 BlockUseCase로 위임하여 도메인 규칙을 한 곳에서 관리하는 방향이 적절합니다.


169-184: BlockUseCase.follow과 UserFollowUseCase.createFollow은 중복이 아닙니다
BlockUseCase.follow은 blockPort를 통한 상태 전이(팔로우/언팔로우)만 처리하며, UserFollowUseCase.createFollow은 별도의 팔로우 엔티티를 생성합니다. 두 호출 모두 유지하세요.

Likely an incorrect or invalid review comment.

src/main/java/com/spoony/spoony_server/domain/user/User.java (1)

29-32: ID-전용 생성자 추가는 적절함

연관 도메인에서 참조용 프록시로 쓰기 좋습니다. 다만 equals/hashCode를 userId 기반으로 오버라이드할지 팀 컨벤션 확인 바랍니다.

src/main/java/com/spoony/spoony_server/application/port/out/user/BlockPort.java (2)

14-16: 도메인 중심 메서드 추가 적절

findByBlockerAndBlocked/saveBlock 도입 좋습니다. 구현체/트랜잭션 경계 일관성만 확인해 주세요.


25-26: 스케줄러 연동 메서드 시그니처 적합

만료 블록 조회/퍼지 시각 마킹은 목적에 부합합니다. Pageable을 받지만 List를 반환하므로, 페이징 일관성(정렬 키 고정 등)만 확인 바랍니다.

src/main/java/com/spoony/spoony_server/application/service/feed/FeedService.java (2)

34-38: UserPort DI 추가는 적절합니다

새 백필/프리필 흐름을 위해 UserPort 주입한 선택 합리적입니다.


44-60: 검증 완료: FeedEntity에 user_id, post_id 복합 유니크 제약이 선언되어 있으며, addFeedsIfNotExists에서 existsByUserIdAndPostId 체크로 중복 삽입을 방지하고 있어 멱등성이 보장됩니다.

src/main/java/com/spoony/spoony_server/domain/user/Block.java (1)

22-37: 빌더 기반 생성자 추가는 도메인 표현력 향상에 도움

상태 전이 관련 타임스탬프를 함께 보존하는 점 좋습니다.

src/main/java/com/spoony/spoony_server/adapter/out/persistence/block/db/BlockEntity.java (2)

15-17: (blocker_id, blocked_id) 유니크 제약 추가 LGTM

중복 관계 방지에 필수 제약입니다. 잘 반영되었습니다.


24-29: FK null 금지 설정 적절

관계 무결성 확보 측면에서 적합합니다.

src/main/java/com/spoony/spoony_server/application/service/Block/BlockService.java (2)

69-75: 증분 백필 기준시각 확인 요청.

changeStatus() 호출 후에도 이전 block 인스턴스의 statusChangedAt을 기준으로 증분 백필을 수행합니다. 의도대로 “언팔 시각 이후”가 맞는지 확인 부탁드립니다. 도메인에서 updateStatus가 이전 값을 덮지 않는 전제라면 OK입니다.


120-127: unblock 시 상태 UNFOLLOWED로 전환하는 설계 재확인.

차단 해제 후 자동 재팔로우가 아닌 UNFOLLOWED로 두는 정책이 요구사항과 일치하는지 확인 바랍니다(클라이언트 UX에도 영향).

src/main/java/com/spoony/spoony_server/application/service/user/UserService.java (1)

187-203: BLOCKED만 노출하는 정책 확인.

신고(REPORT) 상태를 차단 목록에서 제외하는 정책이 기획과 일치하는지 재확인 바랍니다(사용자 혼선 방지).

src/main/java/com/spoony/spoony_server/adapter/out/persistence/block/BlockPersistenceAdapter.java (1)

31-39: 도메인 매퍼 의존 경로 확인.

BlockMapperadapter.out.persistence.user.mapper 패키지에 있으며, 다른 레이어에 동명이존이 없는지 확인 바랍니다. 중복 매퍼가 있다면 단일화 권장.

Comment on lines 66 to 76
// 스케줄러가 만료된 (UNFOLLOWED, BLOCKED) 관계 조회
@Query("""
SELECT b
FROM BlockEntity b
WHERE b.status IN :statuses
AND b.expireAt IS NOT NULL
AND b.expireAt <= :now
""")
List<BlockEntity> findExpiredBlocks(@Param("statuses") List<BlockStatus> statuses,
@Param("now") LocalDateTime now,
Pageable pageable);
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

만료 블록 페이지 조회에 ORDER BY 부재: 페이지 누락/중복 위험

Pageable과 함께 사용시 결정적 정렬이 없으면 만료 스캔 중 삽입/갱신으로 인해 누락/중복 가능성이 큽니다. 안정 정렬 키를 추가하세요.

     @Query("""
         SELECT b
         FROM BlockEntity b
         WHERE b.status IN :statuses
           AND b.expireAt IS NOT NULL
           AND b.expireAt <= :now
+        ORDER BY b.expireAt ASC, b.blockId ASC
     """)
     List<BlockEntity> findExpiredBlocks(@Param("statuses") List<BlockStatus> statuses,
         @Param("now") LocalDateTime now,
         Pageable pageable);

추가로 운영 관점에서 b.status, b.expireAt에 합성 인덱스를 두는 것을 권장합니다.

📝 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
// 스케줄러가 만료된 (UNFOLLOWED, BLOCKED) 관계 조회
@Query("""
SELECT b
FROM BlockEntity b
WHERE b.status IN :statuses
AND b.expireAt IS NOT NULL
AND b.expireAt <= :now
""")
List<BlockEntity> findExpiredBlocks(@Param("statuses") List<BlockStatus> statuses,
@Param("now") LocalDateTime now,
Pageable pageable);
// 스케줄러가 만료된 (UNFOLLOWED, BLOCKED) 관계 조회
@Query("""
SELECT b
FROM BlockEntity b
WHERE b.status IN :statuses
AND b.expireAt IS NOT NULL
AND b.expireAt <= :now
ORDER BY b.expireAt ASC, b.blockId ASC
""")
List<BlockEntity> findExpiredBlocks(@Param("statuses") List<BlockStatus> statuses,
@Param("now") LocalDateTime now,
Pageable pageable);
🤖 Prompt for AI Agents
In
src/main/java/com/spoony/spoony_server/adapter/out/persistence/block/db/BlockRepository.java
around lines 66 to 76, the JPQL query for findExpiredBlocks lacks a
deterministic ORDER BY when used with Pageable which can cause
missing/duplicated results during concurrent inserts/updates; update the query
to include a stable sort (for example ORDER BY b.expireAt ASC, b.id ASC) so
pagination is deterministic, and add a composite DB index on (status, expireAt)
(or status + expireAt + id if supported) to improve scan performance and ensure
the ORDER BY can use the index.

Comment on lines 78 to 89
// 스케줄러가 Feed 삭제 후 라이트로그 기록(JPQL 직접 업데이트)
@Modifying
@Query("""
update BlockEntity b
set b.feedPurgedAt = :now
where b.blocker.userId = :u
and b.blocked.userId = :t
""")
int markFeedPurgedAt(@Param("u") Long u, // 언팔 or 차단한 사람
@Param("t") Long t, // 당한 사람
@Param("now") LocalDateTime now); // 언제 삭제했는지 기록

Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

feedPurgedAt 업데이트의 멱등성 및 과도한 갱신 가능성

  • 기존 값을 덮어쓰면 재시도 시각이 변질됩니다. null일 때만 기록하도록 변경하세요.
  • @Modifying 옵션으로 flush/clear를 켜면 동시성 이슈/캐시 오염을 줄일 수 있습니다.
-    @Modifying
+    @Modifying(clearAutomatically = true, flushAutomatically = true)
     @Query("""
         update BlockEntity b
-        set b.feedPurgedAt = :now
+        set b.feedPurgedAt = COALESCE(b.feedPurgedAt, :now)
         where b.blocker.userId = :u
           and b.blocked.userId = :t
+          and b.feedPurgedAt IS NULL
     """)
     int markFeedPurgedAt(@Param("u") Long u,   // 언팔 or 차단한 사람
         @Param("t") Long t,   // 당한 사람
         @Param("now") LocalDateTime now); // 언제 삭제했는지 기록
🤖 Prompt for AI Agents
In
src/main/java/com/spoony/spoony_server/adapter/out/persistence/block/db/BlockRepository.java
around lines 78 to 89, the JPQL update currently overwrites feedPurgedAt
unconditionally which breaks idempotency on retries and causes unnecessary
updates; change the query to set b.feedPurgedAt = :now only when b.feedPurgedAt
IS NULL (e.g., add "and b.feedPurgedAt IS NULL" to the WHERE clause) and add
@Modifying(clearAutomatically = true, flushAutomatically = true) to reduce
concurrency/cache issues so the update only writes once and repository cache is
refreshed.

Comment on lines 35 to 37
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();

Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

생성 시각은 ORM/DB가 책임지게

필드 초기화(LocalDateTime.now())는 테스트/타임존 불일치 유발. @CreationTimestamp와 updatable=false 사용을 권장합니다.

-    @Column(name = "created_at", nullable = false)
-    private LocalDateTime createdAt = LocalDateTime.now();
+    @org.hibernate.annotations.CreationTimestamp
+    @Column(name = "created_at", nullable = false, updatable = false)
+    private LocalDateTime createdAt;
📝 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
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
@org.hibernate.annotations.CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
🤖 Prompt for AI Agents
In
src/main/java/com/spoony/spoony_server/adapter/out/persistence/feed/db/FeedEntity.java
around lines 35-37, remove the field initializer LocalDateTime.now() from
createdAt and let the database/ORM set the value: annotate the field with
Hibernate's @CreationTimestamp and set @Column(updatable = false, nullable =
false) so the timestamp is populated on insert and not updated; ensure the
proper import for org.hibernate.annotations.CreationTimestamp is added and
remove any direct initialization to avoid test/timezone inconsistencies.

Comment on lines +22 to +24
delete from FeedEntity f
where f.user.userId =:u and f.author.userId = :t
""")
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

삭제 쿼리 성능 보강(인덱스 확인 요청)

where 절이 (user_id, author_id) 조합을 사용합니다. 해당 컬럼 복합 인덱스가 없으면 대량 삭제 시 성능 저하가 큽니다. 스키마에 (user_id, author_id) 복합 인덱스 존재 여부 확인/추가를 권장합니다.

검증 스크립트(DDL/엔티티 인덱스 어노테이션 탐색):

Also applies to: 32-34


🏁 Script executed:

#!/bin/bash
rg -nP --type=java -C2 '@Table\('
rg -nP --type=java -C2 '@Index\(|indexes\s*='
rg -nP -C2 --type=sql 'create\s+index|create\s+unique\s+index'

Length of output: 15137


FeedEntity에 (user_id, author_id) 복합 인덱스 추가 필요
src/main/java/com/spoony/spoony_server/adapter/out/persistence/feed/db/FeedEntity.java:16
@table 어노테이션에 indexes = @Index(name = "idx_feed_user_author", columnList = "user_id, author_id") 를 추가하여 대량 삭제 시 성능 저하 방지

🤖 Prompt for AI Agents
src/main/java/com/spoony/spoony_server/adapter/out/persistence/feed/db/FeedRepository.java
lines 22-24 and
src/main/java/com/spoony/spoony_server/adapter/out/persistence/feed/db/FeedEntity.java
around line 16: add a composite index to the FeedEntity @Table annotation by
setting indexes = @Index(name = "idx_feed_user_author", columnList = "user_id,
author_id"); ensure the javax.persistence.Index (and Table) import is present
and update any DB migration/schema files so the new index is created in
production.

""")
int deleteBidirectional(@Param("u") Long u, @Param("t") Long t);

boolean existsByUserIdAndPostId(Long userId, Long postId);
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

파생 쿼리 메서드 경로 오탈자: 런타임 실패 가능

엔티티가 연관 필드(user, post)를 가지는 구조이므로 파생 쿼리는 중첩 경로를 사용해야 합니다. 현재 시그니처는 파싱 실패 위험이 큽니다.

다음으로 수정 제안:

-    boolean existsByUserIdAndPostId(Long userId, Long postId);
+    boolean existsByUser_UserIdAndPost_PostId(Long userId, Long postId);
📝 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
boolean existsByUserIdAndPostId(Long userId, Long postId);
boolean existsByUser_UserIdAndPost_PostId(Long userId, Long postId);
🤖 Prompt for AI Agents
In
src/main/java/com/spoony/spoony_server/adapter/out/persistence/feed/db/FeedRepository.java
around line 37, the derived query method uses flat property names but the entity
has associations (user, post), so change the method signature to use nested path
properties (e.g. existsByUser_IdAndPost_Id) to match Spring Data JPA parsing;
rename the method accordingly, keep the parameter types as Long, and run/compile
tests to ensure the repository bean loads correctly.

Comment on lines +90 to 110
// 차단 + 신고 관계 유저 ID
Set<Long> excludedUserIds = collectExcludedUserIds(user.getUserId());

List<ZzimPost> zzimPostList =
zzimPostPort.findZzimPostsByUserIdSortedByCreatedAtDesc(user.getUserId());

List<ZzimFocusResponseDTO> responseList = zzimPostList.stream()
.filter(zzimPost -> {
Post post = zzimPost.getPost();
Place place = post.getPlace();
if (place == null) return false;

Long authorId = post.getUser().getUserId();
return place.getPlaceId().equals(command.getPlaceId())
&& !excludedUserIds.contains(authorId);
})
.map(this::toZzimFocusResponse)
.toList();

return ZzimFocusListResponseDTO.of(responseList);
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Focus 리스트도 N+1 위험 있으며 동일한 배치 전략 적용 필요

toZzimFocusResponse에서 사진 리스트/카테고리를 포스트별 조회합니다. getZzimCardList와 동일하게 배치 조회 포트를 도입해 왕복 수를 줄이세요.

🤖 Prompt for AI Agents
In
src/main/java/com/spoony/spoony_server/application/service/zzim/ZzimPostService.java
around lines 90 to 110, the stream mapping to toZzimFocusResponse causes an N+1
by loading photos/categories per post; instead introduce a batch fetch port
(like the one used for getZzimCardList) to load all photos and category data for
the collected postIds in one call, build maps keyed by postId, then replace the
direct toZzimFocusResponse calls with a mapping that supplies the pre-fetched
maps (or overload toZzimFocusResponse to accept those maps) so each post
assembles its DTO from the cached map entries and you preserve the existing
filtering/sorting logic.

Comment on lines +175 to 186
private ZzimCardListResponseDTO getZzimByAreaDong(Long userId, Double lon, Double lat) {
Map<Long, ZzimPost> uniqueZzimPosts = getFilteredUniqueZzimPosts(userId);

List<ZzimCardResponseDTO> responseList = uniqueZzimPosts.values().stream()
.filter(zzimPost -> {
Place place = zzimPost.getPost().getPlace();
return place.getLatitude() != null && place.getLongitude() != null &&
calculateDistance(latitude, longitude, place.getLatitude(), place.getLongitude()) <= 2.0;
})
.map(this::toZzimCardResponse)
.toList();
.filter(zzimPost -> {
Place place = zzimPost.getPost().getPlace();
return place.getLatitude() != null && place.getLongitude() != null &&
calculateDistance(lat, lon, place.getLatitude(), place.getLongitude()) <= 2.0;
})
.map(this::toZzimCardResponse)
.toList();

Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

lat/lon 파라미터 순서 혼동 가능 — 시그니처 정비 및 상수화 권장

메서드 서명은 (lon, lat)지만 calculateDistance에는 (lat, lon) 순서로 전달됩니다. 현재 동작은 맞지만 오탈자 유발 지점입니다. 일반 관례인 (lat, lon) 순서로 서명을 바꾸고 반경은 상수로 추출하세요.

적용 예:

-private ZzimCardListResponseDTO getZzimByAreaDong(Long userId, Double lon, Double lat) {
+private ZzimCardListResponseDTO getZzimByAreaDong(Long userId, Double lat, Double lon) {
   Map<Long, ZzimPost> uniqueZzimPosts = getFilteredUniqueZzimPosts(userId);

   List<ZzimCardResponseDTO> responseList = uniqueZzimPosts.values().stream()
       .filter(zzimPost -> {
           Place place = zzimPost.getPost().getPlace();
           return place.getLatitude() != null && place.getLongitude() != null &&
-              calculateDistance(lat, lon, place.getLatitude(), place.getLongitude()) <= 2.0;
+              calculateDistance(lat, lon, place.getLatitude(), place.getLongitude()) <= AREA_DONG_RADIUS_KM;
       })
       .map(this::toZzimCardResponse)
       .toList();

클래스 상단(필드 영역)에 아래 상수 추가:

private static final double AREA_DONG_RADIUS_KM = 2.0;

getZzimByLocation에서의 호출 인자도 (lat, lon) 순으로 교체하세요.

Comment on lines +190 to 201
private ZzimCardListResponseDTO getZzimByAreaStation(Long userId, Double lon, Double lat) {
Map<Long, ZzimPost> uniqueZzimPosts = getFilteredUniqueZzimPosts(userId);

List<ZzimCardResponseDTO> responseList = uniqueZzimPosts.values().stream()
.filter(zzimPost -> {
Place place = zzimPost.getPost().getPlace();
return place.getLatitude() != null && place.getLongitude() != null &&
calculateDistance(latitude, longitude, place.getLatitude(), place.getLongitude()) <= 1.0;
})
.map(this::toZzimCardResponse)
.toList();
.filter(zzimPost -> {
Place place = zzimPost.getPost().getPlace();
return place.getLatitude() != null && place.getLongitude() != null &&
calculateDistance(lat, lon, place.getLatitude(), place.getLongitude()) <= 1.0;
})
.map(this::toZzimCardResponse)
.toList();

Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

역/스테이션 범위도 동일하게 서명/상수 정비

AreaStation도 (lat, lon) 시그니처와 상수 사용으로 통일하세요.

-private ZzimCardListResponseDTO getZzimByAreaStation(Long userId, Double lon, Double lat) {
+private ZzimCardListResponseDTO getZzimByAreaStation(Long userId, Double lat, Double lon) {
   Map<Long, ZzimPost> uniqueZzimPosts = getFilteredUniqueZzimPosts(userId);

   List<ZzimCardResponseDTO> responseList = uniqueZzimPosts.values().stream()
       .filter(zzimPost -> {
           Place place = zzimPost.getPost().getPlace();
           return place.getLatitude() != null && place.getLongitude() != null &&
-              calculateDistance(lat, lon, place.getLatitude(), place.getLongitude()) <= 1.0;
+              calculateDistance(lat, lon, place.getLatitude(), place.getLongitude()) <= AREA_STATION_RADIUS_KM;
       })
       .map(this::toZzimCardResponse)
       .toList();

클래스 상단에 추가:

private static final double AREA_STATION_RADIUS_KM = 1.0;

Comment on lines +212 to +217
postCategory.getCategory().getCategoryId(),
postCategory.getCategory().getCategoryName(),
postCategory.getCategory().getIconUrlColor(),
postCategory.getCategory().getTextColor(),
postCategory.getCategory().getBackgroundColor()
);
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

사진/카테고리 null 안전성 보강 (NPE 위험 제거)

사진이 없는 게시글일 경우 photo.getPhotoUrl()에서 NPE가 발생할 수 있습니다. null-safe 처리하세요.

-Photo photo = zzimPostPort.findFistPhotoById(post.getPostId());
+Photo photo = zzimPostPort.findFistPhotoById(post.getPostId());
+String photoUrl = (photo != null) ? photo.getPhotoUrl() : null;
 ...
 return ZzimCardResponseDTO.of(
     place.getPlaceId(),
     place.getPlaceName(),
     post.getDescription(),
     place.getPlaceAddress(),
-    photo.getPhotoUrl(),
+    photoUrl,
     place.getLatitude(),
     place.getLongitude(),
     categoryColor
 );

또한 postCategory 또는 그 하위 category가 null일 가능성이 계약상 배제되지 않았다면 사전 검증 또는 안전한 기본값을 적용해 주세요.

Also applies to: 220-227


🏁 Script executed:

#!/bin/bash
set -euxo pipefail

# Locate ZzimPostService.java
service_file=$(fd ZzimPostService.java)
echo "Service file: $service_file"

# Show lines around the snippet (200–250)
sed -n '200,250p' "$service_file"

# Search for the photo lookup and usage
rg -n "findFistPhotoById" -C2 "$service_file"

# Locate ZzimPostPort.java to inspect the method signature
port_file=$(fd ZzimPostPort.java)
echo "Port file: $port_file"

# Show the signature of findFistPhotoById
grep -R "findFistPhotoById" -n "$port_file"

Length of output: 3367


null 안전성 보강

  • toZzimCardResponse: zzimPostPort.findFistPhotoById()가 null을 반환할 수 있으므로
    Photo photo = zzimPostPort.findFistPhotoById(post.getPostId());
    String photoUrl = (photo != null) ? photo.getPhotoUrl() : null;
    와 같이 NPE 방지 후 DTO에 전달
  • toZzimFocusResponse: findPhotoListById()가 null을 반환하지 않는지 계약을 확인하거나, null일 경우 Collections.emptyList()로 변환해 .stream().map(...) 전 null 체크 적용
  • postCategory 또는 postCategory.getCategory()가 null일 가능성을 점검하고, 필요 시 기본값 할당 또는 예외 처리
🤖 Prompt for AI Agents
In
src/main/java/com/spoony/spoony_server/application/service/zzim/ZzimPostService.java
around lines 212-217, guard against possible null returns and NPEs: when calling
zzimPostPort.findFistPhotoById(post.getPostId()) check for null and derive
photoUrl = photo != null ? photo.getPhotoUrl() : null before passing to the DTO;
when calling findPhotoListById(...) ensure the returned list is not null by
using Collections.emptyList() as a fallback before streaming and mapping; and
validate that postCategory and postCategory.getCategory() are non-null (or
provide sensible default values or throw a clear exception) before accessing
categoryId/categoryName/iconUrlColor/textColor.

Comment on lines 52 to 63
public void updateStatus(BlockStatus newStatus, LocalDateTime now, int unfollowedDays, int blockedDays) {
if (this.status == newStatus) return;
this.status = newStatus;
this.statusChangedAt = now;

switch (newStatus) {
case UNFOLLOWED -> this.expireAt = now.plusDays(unfollowedDays);
case BLOCKED -> this.expireAt = now.plusDays(blockedDays);
case FOLLOW -> { this.expireAt = null; this.feedPurgedAt = null; }
case REPORT -> this.expireAt = null;
}
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

도메인 레이어가 adapter 패키지의 BlockStatus에 의존 → 레이어링 위반

  • Domain이 adapter(out.persistence)를 import하면 의존 방향이 뒤집힙니다. BlockStatus를 domain 패키지로 승격하고, persistence는 이를 매핑만 하도록 정리하세요.
  • 향후 새 상태 추가 시 안전망을 위해 switch default 처리도 권장합니다.

default 가드 추가 diff:

         switch (newStatus) {
             case UNFOLLOWED -> this.expireAt = now.plusDays(unfollowedDays);
             case BLOCKED -> this.expireAt = now.plusDays(blockedDays);
             case FOLLOW -> { this.expireAt = null; this.feedPurgedAt = null; }
             case REPORT -> this.expireAt = null;
+            default -> throw new IllegalArgumentException("Unsupported status: " + newStatus);
         }
📝 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
public void updateStatus(BlockStatus newStatus, LocalDateTime now, int unfollowedDays, int blockedDays) {
if (this.status == newStatus) return;
this.status = newStatus;
this.statusChangedAt = now;
switch (newStatus) {
case UNFOLLOWED -> this.expireAt = now.plusDays(unfollowedDays);
case BLOCKED -> this.expireAt = now.plusDays(blockedDays);
case FOLLOW -> { this.expireAt = null; this.feedPurgedAt = null; }
case REPORT -> this.expireAt = null;
}
}
public void updateStatus(BlockStatus newStatus, LocalDateTime now, int unfollowedDays, int blockedDays) {
if (this.status == newStatus) return;
this.status = newStatus;
this.statusChangedAt = now;
switch (newStatus) {
case UNFOLLOWED -> this.expireAt = now.plusDays(unfollowedDays);
case BLOCKED -> this.expireAt = now.plusDays(blockedDays);
case FOLLOW -> { this.expireAt = null; this.feedPurgedAt = null; }
case REPORT -> this.expireAt = null;
default -> throw new IllegalArgumentException("Unsupported status: " + newStatus);
}
}
🤖 Prompt for AI Agents
In src/main/java/com/spoony/spoony_server/domain/user/Block.java around lines 52
to 63, the domain class currently depends on BlockStatus from the
adapter/persistence package which inverts layering: move/define BlockStatus into
the domain package (e.g., com.spoony.spoony_server.domain.user) and update
persistence adapters to map their own enums to the domain enum; then update
imports in this file to use the domain BlockStatus. Also add a default branch to
the switch in updateStatus to handle unknown/future statuses (e.g., throw
IllegalStateException or log and no-op) so new enum values don’t silently bypass
expected handling.

@Enumerated(EnumType.STRING)
@Column(length = 50)
private BlockStatus status;

Copy link
Contributor

Choose a reason for hiding this comment

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

status도 nullable=true가 들어갔음 싶네요

}

//스케쥴러 작동 여부에 대해 라이트로그 찍는 매서드
// 스케줄러가 실제 Feed 삭제했을 때 라이트로그 기록
Copy link
Contributor

Choose a reason for hiding this comment

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

라이트로그와 백필전략에 관해서 다음번에 구두로도 설명 부탁드립니다!!!

""")
int deleteBidirectional(@Param("u") Long u, @Param("t") Long t);

boolean existsByUserIdAndPostId(Long userId, Long postId);
Copy link
Contributor

Choose a reason for hiding this comment

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

이 부분 실행이 잘 되나요??

@HUIJAEKO
Copy link
Contributor

HUIJAEKO commented Sep 6, 2025

file changed 30개 ㄷㄷㄷ.... 고생하셨습니다

@HUIJAEKO
Copy link
Contributor

HUIJAEKO commented Sep 6, 2025

UserController API는 내부 로직만 바뀌는거라 크게 상관이 없을듯 하네요

Copy link
Member

@heogeonho heogeonho left a comment

Choose a reason for hiding this comment

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

회의날 발표해주신 자료를 통해 이해 완료 하였습니다!
변경량이 많아 파악하는데 시간이 오래걸린 것 같아요! 다음 작업부터는 양이 많아지면 PR을 분리하는 방안도 고려해보면 좋을 것 같습니다!

고생하셨습니다!!

}

@Override
public FollowListResponseDTO getFollowers(FollowGetCommand command) {
Copy link
Member

Choose a reason for hiding this comment

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

수민님 작업 공간에서 삭제되어 있었던 것 같아요! 다음엔 브랜치 파기 전에 pull 한번 하고 작업 진행하는게 좋을 것 같습니다!!

@heogeonho
Copy link
Member

image image

getFollowers / getFollowings 기능 추가(삭제 되어있었어요,, 혹시 이 부분 일부러 날리신거면 알려주세요)
제 로컬 dev 확인해봤는데 살아있습니다...!

@heogeonho heogeonho force-pushed the develop branch 2 times, most recently from cf032d9 to 6b88dd6 Compare September 24, 2025 23:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐇수민🥕 개쩌는 개발자 이수민 💡FEAT 새로운 기능 추가

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT] Feed 테이블 정합성 관리 및 스케쥴러 처리

4 participants