Skip to content

Comments

recommendation: split God Class into 5 services + remove legacy files#153

Merged
ryuwldnjs merged 4 commits intomainfrom
152-refactor-recommendationservice-책임-분리-레거시-정리
Feb 14, 2026

Hidden character warning

The head ref may contain hidden characters: "152-refactor-recommendationservice-\ucc45\uc784-\ubd84\ub9ac-\ub808\uac70\uc2dc-\uc815\ub9ac"
Merged

recommendation: split God Class into 5 services + remove legacy files#153
ryuwldnjs merged 4 commits intomainfrom
152-refactor-recommendationservice-책임-분리-레거시-정리

Conversation

@ryuwldnjs
Copy link
Member

@ryuwldnjs ryuwldnjs commented Feb 14, 2026

  • RecommendationService (9 deps): CRUD + 조회
  • ScheduledRecommendationService: 배치 오케스트레이션
  • RecommendationEmailService: 이메일 발송
  • RecommendationCreator: 공통 생성 로직 (package-private)
  • MissionCyclePolicy: 미션 사이클 정책 (package-private)

레거시 정리: TeamRecommendation, RecommendationStatus 등 5개 삭제
스케줄러 이동: infrastructure/scheduler/ → recommendation/scheduler/

관련 이슈

변경 내용

변경 유형

  • [FEATURE] 기능 구현
  • [BUG] 버그 수정
  • [REFACTOR] 리팩토링
  • [CHORE] 설정/의존성
  • [DOCS] 문서

테스트

  • 테스트 완료

스크린샷 (UI 변경 시)

참고사항

Summary by CodeRabbit

릴리스 노트

  • New Features
    • 추천 흐름 재구성: 생성·스케줄링·이메일 전송 책임 분리 및 상세 추천 응답 도입
  • Bug Fixes
    • 이메일 발송 실패 처리 강화 및 배치/타이밍 로그 개선
  • Chores
    • 수동 추천 API에서 요청별 count 제거 및 응답 단순화
    • 레거시 추천 엔티티·레포지토리 정리로 도메인 축소
  • Tests
    • 스케줄러·추천 생성·이메일 전송 관련 단위 테스트 추가·수정

- RecommendationService (9 deps): CRUD + 조회
- ScheduledRecommendationService: 배치 오케스트레이션
- RecommendationEmailService: 이메일 발송
- RecommendationCreator: 공통 생성 로직 (package-private)
- MissionCyclePolicy: 미션 사이클 정책 (package-private)

레거시 정리: TeamRecommendation, RecommendationStatus 등 5개 삭제
스케줄러 이동: infrastructure/scheduler/ → recommendation/scheduler/
@ryuwldnjs ryuwldnjs linked an issue Feb 14, 2026 that may be closed by this pull request
@coderabbitai
Copy link

coderabbitai bot commented Feb 14, 2026

Note

.coderabbit.yaml has unrecognized properties

CodeRabbit is using all valid settings from your configuration. Unrecognized properties (listed below) have been ignored and may indicate typos or deprecated fields that can be removed.

⚠️ Parsing warnings (1)
Validation error: Unrecognized key(s) in object: 'path_filters'
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Walkthrough

추천 기능을 책임별로 분리하고 레거시 TeamRecommendation 계층을 제거했습니다. RecommendationService는 Creator·EmailService로 위임되며 스케줄러·메일 빌더 패키지 재배치, 새로운 DTO 및 다수의 서비스·테스트 추가/수정이 이뤄졌습니다.

Changes

Cohort / File(s) Summary
컨트롤러
src/main/java/com/ryu/studyhelper/recommendation/RecommendationController.java
수동 추천 엔드포인트의 count 파라미터 제거, 반환 DTO를 TeamRecommendationDetailResponseRecommendationDetailResponse로 변경 및 관련 import/주석·로그 조정.
핵심 서비스 재배치
src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationService.java, .../ScheduledRecommendationService.java, .../RecommendationEmailService.java, .../RecommendationCreator.java, .../MissionCyclePolicy.java
RecommendationService를 오케스트레이터로 재구성; ScheduledRecommendationService, RecommendationEmailService, RecommendationCreator, MissionCyclePolicy 신규 추가 및 책임 분리.
레거시 엔티티/리포지토리 삭제
src/main/java/com/ryu/studyhelper/recommendation/domain/team/TeamRecommendation.java, .../TeamRecommendationProblem.java, .../RecommendationStatus.java, src/main/java/com/ryu/studyhelper/recommendation/repository/TeamRecommendationRepository.java, .../TeamRecommendationProblemRepository.java
레거시 TeamRecommendation 관련 엔티티, enum 및 관련 리포지토리 전부 제거.
DTO 변경
src/main/java/com/ryu/studyhelper/recommendation/dto/response/RecommendationDetailResponse.java, .../TeamRecommendationDetailResponse.java
RecommendationDetailResponse 레코드 추가 및 기존 TeamRecommendationDetailResponse 제거.
스케줄러·패키지 이동
src/main/java/com/ryu/studyhelper/recommendation/scheduler/ProblemRecommendationScheduler.java, .../EmailSendScheduler.java, .../mailbuilder/RecommendationMailBuilder.java
스케줄러 패키지로 이동, EmailSendScheduler/ProblemRecommendationScheduler가 각각 RecommendationEmailService/ScheduledRecommendationService 호출로 변경; RecommendationMailBuilder 패키지명 변경.
도메인 정리
src/main/java/com/ryu/studyhelper/team/domain/Team.java
teamRecommendations 연관 필드 및 import 제거.
테스트 변경/추가
src/test/java/.../RecommendationServiceTest.java, .../ScheduledRecommendationServiceTest.java, .../RecommendationCreatorTest.java, .../RecommendationEmailServiceTest.java
RecommendationService 테스트 재작업(creator/email 서비스 중심), ScheduledRecommendationService·RecommendationCreator·RecommendationEmailService용 신규 테스트 추가 및 패키지/시나리오 조정.

Sequence Diagram(s)

sequenceDiagram
    rect rgba(200,230,255,0.5)
    actor Client
    participant Controller as RecommendationController
    participant Service as RecommendationService
    participant Creator as RecommendationCreator
    participant EmailService as RecommendationEmailService
    participant DB as Repositories
    end

    Client->>Controller: POST /teams/{id}/recommendations (manual)
    Controller->>Service: createManualRecommendation(teamId)
    Service->>Service: validateNoRecommendationToday(teamId)
    Service->>Creator: create(team, MANUAL)
    Creator->>DB: save Recommendation, RecommendationProblem, MemberRecommendation
    Creator-->>Service: created Recommendation + problems + memberRecommendations
    Service->>EmailService: send(list<MemberRecommendation>)
    EmailService->>DB: update MemberRecommendation status (SENT/FAILED)
    EmailService-->>Service: send 결과 집계
    Service-->>Controller: RecommendationDetailResponse
    Controller-->>Client: 200 OK
Loading
sequenceDiagram
    rect rgba(200,230,255,0.5)
    actor Scheduler
    participant ProblemScheduler as ProblemRecommendationScheduler
    participant ScheduledService as ScheduledRecommendationService
    participant Creator as RecommendationCreator
    participant DB as Repositories
    end

    Scheduler->>ProblemScheduler: scheduled trigger (06:00)
    ProblemScheduler->>ScheduledService: prepareDailyRecommendations()
    ScheduledService->>DB: getActiveTeams(date)
    loop per active team
        ScheduledService->>DB: exists recommendation in mission cycle?
        alt not exists
            ScheduledService->>Creator: create(team, SCHEDULED)
            Creator->>DB: persist recommendation entities
            Creator-->>ScheduledService: success/failure
        end
    end
    ScheduledService-->>ProblemScheduler: complete
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45분

Possibly related PRs

Poem

🐰
옛 골격 벗겨 새 길 닦았네,
Creator가 씨 뿌리고 Mail이 편지하네,
여섯 시에 팀들 모여 문제 줄 세우고,
응답은 DTO에 빛나네,
당근 한 입에 배포 완료—깡충! 🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection ⚠️ Warning ⚠️ Unable to check for merge conflicts: Invalid branch name format
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main objective: splitting the monolithic RecommendationService into 5 focused services and removing legacy files.
Linked Issues check ✅ Passed The PR fully addresses issue #152: RecommendationService split into 5 services (RecommendationService, ScheduledRecommendationService, RecommendationEmailService, RecommendationCreator, MissionCyclePolicy), legacy entities/repositories removed, package structure reorganized, and tests updated accordingly.
Out of Scope Changes check ✅ Passed All changes directly align with the refactoring objectives: service splitting, legacy cleanup, file reorganization, and test updates. No unrelated changes detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch 152-refactor-recommendationservice-책임-분리-레거시-정리
⚔️ Resolve merge conflicts (beta)
  • Auto-commit resolved conflicts to branch 152-refactor-recommendationservice-책임-분리-레거시-정리
  • Create stacked PR with resolved conflicts
  • Post resolved changes as copyable diffs 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

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

Caution

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

⚠️ Outside diff range comments (1)
src/test/java/com/ryu/studyhelper/recommendation/service/RecommendationServiceTest.java (1)

265-293: ⚠️ Potential issue | 🟡 Minor

EmailSendableTimeValidation 테스트가 코드베이스에 존재하지 않는 로직을 테스트합니다.

이 테스트는 이메일 발송 가능 시간 로직(9시 이후 또는 1시 이전)을 검증하지만, 실제 구현에서는 EmailSendSchedulerRecommendationEmailService 모두 단순히 매일 9시에 스케줄된 배치 작업으로 이메일을 발송합니다. 시간 검증 로직이 없으므로 이 테스트는 orphaned 상태입니다. 해당 테스트를 제거하거나, 실제 스케줄 동작(9시 배치 실행)을 검증하는 테스트로 리팩토링해 주세요.

🤖 Fix all issues with AI agents
In
`@src/main/java/com/ryu/studyhelper/recommendation/dto/response/RecommendationDetailResponse.java`:
- Around line 41-65: The static factory hardcodes status="SENT" and
sentAt=LocalDateTime.now() which misrepresents the domain and breaks test
determinism; update RecommendationDetailResponse.from to read the real values
from the domain (use recommendation.getStatus() and recommendation.getSentAt()
if available) and stop calling LocalDateTime.now() directly—instead add a Clock
parameter to the method signature (or accept a sentAt value) and use
LocalDateTime.now(clock) so callers can supply a testable clock or the actual
recommendation.sentAt as fallback.

In
`@src/main/java/com/ryu/studyhelper/recommendation/scheduler/EmailSendScheduler.java`:
- Around line 36-39: The catch block in EmailSendScheduler has inconsistent
indentation before the log.error call; remove the extra leading whitespace so
the log.error line aligns with the surrounding block, keeping the existing
parameters (endTime, startTime, e) unchanged; locate the catch(Exception e)
block in EmailSendScheduler and adjust the indentation of the log.error(...)
statement so it matches the closing brace and the long endTime calculation uses
the same indentation level as the declaration of endTime.

In
`@src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationCreator.java`:
- Around line 41-50: The create(Team team, RecommendationType type) method in
RecommendationCreator participates in the caller's transaction which causes
ScheduledRecommendationService.prepareDailyRecommendations() to mark the entire
transaction rollback-only when an exception occurs for one team; change
create(...) to run in its own transaction by annotating the method with
`@Transactional`(propagation = Propagation.REQUIRES_NEW) (and add the necessary
import for org.springframework.transaction.annotation.Propagation and
org.springframework.transaction.annotation.Transactional) so each team's
Recommendation, RecommendationProblem, and MemberRecommendation persist/rollback
independently.

In
`@src/main/java/com/ryu/studyhelper/recommendation/service/ScheduledRecommendationService.java`:
- Around line 22-26: The class-level `@Transactional` on
ScheduledRecommendationService causes the entire prepareDailyRecommendations()
run to share one transaction so a RuntimeException in one team marks the
transaction rollback-only and undoes other teams' work; fix by making team
processing transactional and isolated: either add `@Transactional`(propagation =
Propagation.REQUIRES_NEW) to RecommendationCreator.create() so each team's
create() runs in its own transaction, or remove the class-level `@Transactional`
from ScheduledRecommendationService and apply `@Transactional` only to the
specific methods that must be transactional (e.g., wrap per-team processing or
the methods that commit changes) so team-level try-catch actually prevents
rollbacks of other teams.

In
`@src/test/java/com/ryu/studyhelper/recommendation/service/ScheduledRecommendationServiceTest.java`:
- Around line 98-123: The test manualAt0AM_notFoundBySchedulerAt6AM currently
duplicates the first test; instead prepare a Recommendation entity with
createdAt = LocalDateTime.parse("2025-01-15T00:00:00") and stub
recommendationRepository.findFirstByTeamIdAndCreatedAtBetweenOrderById so that a
call with the midnight range (00:00) would return that recommendation, while the
scheduler's actual query at 06:00
(LocalDateTime.parse("2025-01-15T06:00:00")..06:00) returns Optional.empty;
update the mocks in ScheduledRecommendationServiceTest and keep the verify call
to ensure findFirstByTeamIdAndCreatedAtBetweenOrderById was invoked with the
06:00 range, proving the 00:00 manual recommendation is excluded from the 06:00
mission cycle.
🧹 Nitpick comments (11)
src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationCreator.java (1)

60-76: 개별 save() 호출 대신 saveAll() 사용을 고려해 보세요.

RecommendationProblemMemberRecommendation을 반복문 안에서 개별 save()로 저장하고 있습니다. saveAll()을 사용하면 배치 처리로 DB 라운드트립을 줄일 수 있습니다.

♻️ saveAll() 적용 예시
     private List<Problem> createRecommendationProblems(Recommendation recommendation, Team team) {
         List<Problem> problems = recommendProblemsForTeam(team);
+        List<RecommendationProblem> rps = new java.util.ArrayList<>();
         for (Problem problem : problems) {
             RecommendationProblem rp = RecommendationProblem.create(problem);
             recommendation.addProblem(rp);
-            recommendationProblemRepository.save(rp);
+            rps.add(rp);
         }
+        recommendationProblemRepository.saveAll(rps);
         return problems;
     }

     private void createMemberRecommendations(Recommendation recommendation, Team team) {
         List<Member> teamMembers = teamMemberRepository.findMembersByTeamId(team.getId());
+        List<MemberRecommendation> mrs = new java.util.ArrayList<>();
         for (Member member : teamMembers) {
             MemberRecommendation mr = MemberRecommendation.create(member, recommendation, team);
-            memberRecommendationRepository.save(mr);
+            mrs.add(mr);
         }
+        memberRecommendationRepository.saveAll(mrs);
     }
src/main/java/com/ryu/studyhelper/recommendation/service/ScheduledRecommendationService.java (1)

69-76: findAll() + lazy loading으로 인한 N+1 쿼리 가능성이 있습니다.

teamRepository.findAll()로 전체 팀을 로드한 후 team.getTeamMembers().isEmpty()에서 각 팀마다 lazy loading이 발생할 수 있습니다. 배치 작업(새벽 6시)이므로 즉시 문제가 되진 않지만, 팀 수가 늘어나면 성능에 영향을 줄 수 있습니다.

src/main/java/com/ryu/studyhelper/recommendation/scheduler/ProblemRecommendationScheduler.java (1)

41-59: 프로덕션 코드에 테스트/디버그용 메서드가 남아있습니다.

testPrepareRecommendations()는 주석 처리된 @Scheduled와 함께 디버그 용도로 추가된 메서드입니다. prepareDailyRecommendations()와 거의 동일한 코드가 중복되며, 프로덕션에 배포할 필요가 없는 코드입니다.

로컬 테스트가 필요하다면 통합 테스트나 프로파일 기반 설정으로 분리하는 것이 좋습니다.

♻️ 테스트 메서드 제거
-
-    // 테스트용 첫 실행후 1초뒤에 딱 한번 문제 추천 배치 작업 시작
-//    `@Scheduled`(initialDelay = 1000, fixedDelay = Long.MAX_VALUE)
-    public void testPrepareRecommendations() {
-        log.info("=== [테스트] 문제 추천 배치 작업 시작 ===");
-
-        long startTime = System.currentTimeMillis();
-
-        try {
-            scheduledRecommendationService.prepareDailyRecommendations();
-
-            long endTime = System.currentTimeMillis();
-            log.info("=== [테스트] 문제 추천 배치 작업 완료 === (소요시간: {}ms)", endTime - startTime);
-
-        } catch (Exception e) {
-            long endTime = System.currentTimeMillis();
-            log.error("=== [테스트] 문제 추천 배치 작업 실패 === (소요시간: {}ms)", endTime - startTime, e);
-        }
-    }
 }
src/test/java/com/ryu/studyhelper/recommendation/service/ScheduledRecommendationServiceTest.java (1)

156-176: teamMembers.add(new Object()) — 타입 안전성이 떨어지는 리플렉션 사용입니다.

List<TeamMember>new Object()를 추가하고 있습니다. 현재는 isEmpty() 체크만 사용하므로 동작하지만, 향후 getActiveTeams 필터링 로직이 변경되어 리스트 원소에 접근하면 ClassCastException이 발생합니다. mock TeamMember를 사용하는 것이 더 안전합니다.

♻️ 타입 안전한 TeamMember 사용
-            `@SuppressWarnings`("unchecked")
-            java.util.List<Object> teamMembers = (java.util.List<Object>) teamMembersField.get(team);
-            teamMembers.add(new Object());
+            `@SuppressWarnings`("unchecked")
+            java.util.List<com.ryu.studyhelper.team.domain.TeamMember> teamMembers =
+                    (java.util.List<com.ryu.studyhelper.team.domain.TeamMember>) teamMembersField.get(team);
+            teamMembers.add(org.mockito.Mockito.mock(com.ryu.studyhelper.team.domain.TeamMember.class));
src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationService.java (2)

61-78: 트랜잭션 내에서 이메일 발송 시 DB 커넥션이 오래 점유될 수 있습니다.

createManualRecommendation이 클래스 레벨 @Transactional 내에서 실행되므로, recommendationEmailService.send() (SMTP 호출)가 완료될 때까지 DB 커넥션이 유지됩니다. 팀원이 많거나 SMTP 서버가 느릴 경우 커넥션 풀 고갈 위험이 있습니다.

이메일 발송을 트랜잭션 밖으로 분리하거나, @Transactional(propagation = REQUIRES_NEW)RecommendationEmailService.send()에 적용하거나, @Async로 비동기 처리하는 것을 권장합니다.

♻️ 트랜잭션과 이메일 발송 분리 예시

추천 생성과 이메일 발송을 분리하는 방법:

+    `@Transactional`
+    public Recommendation createManualRecommendationInternal(Long teamId) {
+        Team team = teamRepository.findById(teamId)
+                .orElseThrow(() -> new CustomException(CustomResponseStatus.TEAM_NOT_FOUND));
+        validateNoRecommendationToday(teamId);
+        return recommendationCreator.create(team, RecommendationType.MANUAL);
+    }
+
     public RecommendationDetailResponse createManualRecommendation(Long teamId) {
-        Team team = teamRepository.findById(teamId)
-                .orElseThrow(() -> new CustomException(CustomResponseStatus.TEAM_NOT_FOUND));
-
-        validateNoRecommendationToday(teamId);
-
-        Recommendation recommendation = recommendationCreator.create(team, RecommendationType.MANUAL);
-
-        // 즉시 이메일 발송
+        Recommendation recommendation = createManualRecommendationInternal(teamId);
+        Team team = teamRepository.findById(teamId).orElseThrow();
+
+        // 트랜잭션 커밋 후 이메일 발송
         List<MemberRecommendation> memberRecommendations =
                 memberRecommendationRepository.findByRecommendationId(recommendation.getId());
         recommendationEmailService.send(memberRecommendations);

117-135: getMyTodayProblems에서 팀별 N+1 쿼리 패턴이 존재합니다.

각 팀에 대해 findTodayRecommendation을 호출하면서 추천 조회 + 문제 조회 + 태그 조회가 반복됩니다. 사용자가 속한 팀 수가 많아지면 쿼리 수가 선형적으로 증가합니다.

현재 일반적인 사용 패턴(소수 팀)에서는 허용 범위이나, 향후 성능 이슈 발생 시 배치 쿼리로의 최적화를 고려해 주세요.

src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationEmailService.java (2)

21-25: sendAll()이 단일 트랜잭션에서 전체 배치를 처리합니다.

클래스 레벨 @Transactional로 인해 sendAll()의 모든 이메일 발송과 상태 업데이트가 하나의 트랜잭션에서 수행됩니다. 대량 이메일 발송 시 다음 문제가 발생할 수 있습니다:

  1. 프로세스 중단 시 모든 상태 업데이트(markEmailAsSent/Failed)가 롤백됨
  2. 장시간 DB 커넥션 점유

배치 처리 시 건별 또는 청크 단위 트랜잭션 분리를 고려해 주세요.

♻️ 청크 단위 트랜잭션 분리 예시

sendEmail을 별도 트랜잭션으로 분리하는 접근:

+    `@Transactional`(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW)
     private boolean sendEmail(MemberRecommendation mr) {

또는 sendAll()에서 @Transactional을 제거하고, sendEmail을 self-injection 등으로 별도 트랜잭션 메서드로 추출하는 방법도 있습니다.


62-66: send()에서 발송 결과 로깅이 누락되어 있습니다.

sendAll()과 달리 send()에서는 성공/실패 건수를 추적하거나 로깅하지 않습니다. 수동 추천 이메일 발송 시에도 결과 요약 로그가 있으면 운영 모니터링에 도움이 됩니다.

♻️ 결과 로깅 추가 예시
     public void send(List<MemberRecommendation> memberRecommendations) {
+        int successCount = 0;
+        int failCount = 0;
         for (MemberRecommendation mr : memberRecommendations) {
-            sendEmail(mr);
+            if (sendEmail(mr)) {
+                successCount++;
+            } else {
+                failCount++;
+            }
         }
+        log.info("수동 추천 이메일 발송 완료 - 대상: {}개, 성공: {}개, 실패: {}개",
+                memberRecommendations.size(), successCount, failCount);
     }
src/main/java/com/ryu/studyhelper/recommendation/scheduler/EmailSendScheduler.java (1)

42-60: testsendPendingEmails 메서드: 네이밍 위반, 코드 중복, 주석 처리된 어노테이션.

몇 가지 문제가 있습니다:

  1. 네이밍: testsendPendingEmailstestSendPendingEmails (lowerCamelCase 위반)
  2. 코드 중복: sendPendingEmails()와 동일한 로직이 그대로 복사되어 있습니다.
  3. 주석 처리된 @Scheduled: 프로덕션 코드에 테스트용 주석 코드가 남아 있습니다.

테스트용 메서드가 정말 필요하다면 프로파일 조건(@Profile("local"))을 사용하고, 로직 중복은 기존 sendPendingEmails()를 호출하는 방식으로 해결해 주세요. 불필요하다면 제거를 권장합니다.

♻️ 제거 또는 리팩토링 제안

옵션 1: 제거 (권장)

-    // 테스트용 첫 실행후 10초뒤에 딱 한번 문제 추천 배치 작업 시작
-//    `@Scheduled`(initialDelay = 10000, fixedDelay = Long.MAX_VALUE)
-    public void testsendPendingEmails() {
-        log.info("=== 이메일 발송 배치 작업 시작 ===");
-
-        long startTime = System.currentTimeMillis();
-
-        try {
-            recommendationEmailService.sendAll();
-
-            long endTime = System.currentTimeMillis();
-            log.info("=== 이메일 발송 배치 작업 완료 === (소요시간: {}ms)", endTime - startTime);
-
-        } catch (Exception e) {
-            long endTime = System.currentTimeMillis();
-            log.error("=== 이메일 발송 배치 작업 실패 === (소요시간: {}ms)", endTime - startTime, e);
-        }
-    }

옵션 2: 프로파일 기반으로 유지 시

+    `@Profile`("local")
+    `@Scheduled`(initialDelay = 10000, fixedDelay = Long.MAX_VALUE)
-//    `@Scheduled`(initialDelay = 10000, fixedDelay = Long.MAX_VALUE)
-    public void testsendPendingEmails() {
-        ...중복 코드...
-    }
+    public void testSendPendingEmails() {
+        sendPendingEmails();
+    }
src/test/java/com/ryu/studyhelper/recommendation/service/RecommendationServiceTest.java (2)

464-506: 리플렉션 기반 헬퍼 메서드가 내부/외부 클래스에 중복되어 있습니다.

createTeamWithIdAndName이 내부 클래스 GetMyTodayProblemsValidation(Line 486)과 유사한 패턴의 createTeamWithId가 외부 클래스(Line 511)에 각각 존재합니다. setRecommendationId도 내부 클래스에만 있고, createMemberWithId도 내부 클래스에만 있습니다.

이 헬퍼들을 외부 클래스 레벨로 통합하면 중복이 줄고 유지보수가 편해집니다.

Also applies to: 509-543


141-142: Mock 반환값에 LocalDateTime.now() 사용이 시스템 시계에 의존합니다.

createRecommendationWithCreatedAt(TEAM_ID, LocalDateTime.now())에서 LocalDateTime.now()는 시스템 시계를 사용합니다. 테스트의 다른 부분에서는 fixedClock을 사용하고 있으므로, 여기서도 고정된 시간을 사용하는 것이 일관적입니다. 현재는 Mock 반환값이므로 실제 테스트 결과에 영향은 없지만, 가독성과 일관성을 위해 명시적인 시간을 사용하는 것을 권장합니다.

♻️ 고정 시간 사용 예시
             when(recommendationCreator.create(any(), any()))
-                    .thenReturn(createRecommendationWithCreatedAt(TEAM_ID, LocalDateTime.now()));
+                    .thenReturn(createRecommendationWithCreatedAt(TEAM_ID, LocalDateTime.parse(dateTime)));

Comment on lines +22 to +26
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class ScheduledRecommendationService {
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

클래스 레벨 @Transactional과 팀별 try-catch가 충돌합니다.

@Transactional이 클래스 레벨에 적용되어 prepareDailyRecommendations() 전체가 하나의 트랜잭션으로 실행됩니다. 특정 팀에서 RuntimeException이 발생하면 catch로 잡더라도 트랜잭션이 rollback-only로 마킹되어, 이미 성공한 다른 팀의 데이터도 롤백됩니다.

팀별 독립 처리를 보장하려면 RecommendationCreator.create()@Transactional(propagation = Propagation.REQUIRES_NEW)를 적용하거나, 클래스 레벨 @Transactional을 제거하고 개별 메서드에만 적용하는 방법을 고려해 주세요.

🔧 수정 방안 1: RecommendationCreator에 REQUIRES_NEW 적용

RecommendationCreator.java에서:

+    `@Transactional`(propagation = Propagation.REQUIRES_NEW)
     Recommendation create(Team team, RecommendationType type) {

ScheduledRecommendationService.java에서:

 `@Service`
 `@RequiredArgsConstructor`
-@Transactional
 `@Slf4j`
 public class ScheduledRecommendationService {
🤖 Prompt for AI Agents
In
`@src/main/java/com/ryu/studyhelper/recommendation/service/ScheduledRecommendationService.java`
around lines 22 - 26, The class-level `@Transactional` on
ScheduledRecommendationService causes the entire prepareDailyRecommendations()
run to share one transaction so a RuntimeException in one team marks the
transaction rollback-only and undoes other teams' work; fix by making team
processing transactional and isolated: either add `@Transactional`(propagation =
Propagation.REQUIRES_NEW) to RecommendationCreator.create() so each team's
create() runs in its own transaction, or remove the class-level `@Transactional`
from ScheduledRecommendationService and apply `@Transactional` only to the
specific methods that must be transactional (e.g., wrap per-team processing or
the methods that commit changes) so team-level try-catch actually prevents
rollbacks of other teams.

- 죽은 테스트 EmailSendableTimeValidation 제거
- 내부 helper를 클래스 레벨로 승격 (createTeamMember, setRecommendationId 등)
- reflection helper를 setFieldValue로 통합
- MEMBER_ID 클래스 상수로 승격, 중복 TEAM_ID_1 제거
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: 1

🤖 Fix all issues with AI agents
In
`@src/main/java/com/ryu/studyhelper/recommendation/scheduler/EmailSendScheduler.java`:
- Around line 41-60: Remove the duplicate test/debug method
testsendPendingEmails(): delete the entire testsendPendingEmails() method
(including the commented `@Scheduled`) and rely on the existing
sendPendingEmails() implementation; if a one-off test run is needed, implement
it in unit/integration tests or temporarily invoke sendPendingEmails() from test
code instead of leaving a debug method in production, and if you intend to keep
any helper, rename it to follow lowerCamelCase (e.g., testSendPendingEmails) but
preferably remove it to satisfy DRY and naming guidelines.

- 스케줄러 테스트용 dead method 제거
- RecommendationCreator 예외를 CustomException으로 통일
- deprecated GET /team/{teamId}/today-problem 엔드포인트 제거
- 미사용 getTodayRecommendation 서비스 메서드 제거
- RecommendationCreatorTest, RecommendationEmailServiceTest 추가
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: 1

🤖 Fix all issues with AI agents
In
`@src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationCreator.java`:
- Around line 43-52: 현재 create(Team team, RecommendationType type)가 먼저
createRecommendation(...)로 DB에 Recommendation을 저장한 뒤
createRecommendationProblems(...)에서 recommendProblemsForTeam(...)을 통해 핸들 검증을 수행해
예외가 발생할 수 있습니다; 이 순서를 바꿔 create() 시작부에서 recommendProblemsForTeam(...)이나 새 검증
메서드(예: validateTeamHasAuthenticatedHandles(team) 또는
ensureValidatedHandles(team))를 호출해 인증된 핸들 유무를 확인하고 예외가 있을 경우 즉시 반환/예외 처리하여 DB에
Recommendation을 저장(createRecommendation(...))하거나
createMemberRecommendations(...)를 호출하기 전에 불필요한 쓰기를 방지하세요. 이 변경은
createRecommendationProblems(...), recommendProblemsForTeam(...) 호출 시점과
createRecommendation(...) 호출 순서를 뒤바꾸는 방식으로 적용하세요.
🧹 Nitpick comments (5)
src/main/java/com/ryu/studyhelper/common/enums/CustomResponseStatus.java (1)

93-94: NO_VERIFIED_HANDLE의 섹션 배치가 의미적으로 불일치합니다.

NO_VERIFIED_HANDLEHttpStatus.BAD_REQUEST (400)을 사용하는 클라이언트 요청 검증 오류이지만, 6000: Server_Error 섹션에 배치되어 있습니다. 5000: NOT_MATCH 섹션의 다른 BAD_REQUEST 상태들(예: INVALID_PROBLEM_LEVEL_RANGE, PROBLEM_NOT_SOLVED_YET)과 함께 배치하는 것이 코드 그룹의 의미와 일치합니다.

또한, Sentry 설정(SentryConfig.java)에서 httpStatus < 500CustomException은 Sentry로 전송하지 않으므로 동작에는 영향이 없지만, 향후 코드 범위 기반 로직이 추가될 경우 혼란을 줄 수 있습니다.

src/test/java/com/ryu/studyhelper/recommendation/service/RecommendationEmailServiceTest.java (2)

136-151: send() 테스트에서 상태 변경 및 저장 검증이 누락되었습니다.

sendsToAllMembers 테스트는 mailSender.send() 호출만 검증하고 있습니다. sendsPendingEmails 테스트처럼 EmailSendStatus.SENT 상태 변경과 memberRecommendationRepository.save() 호출도 검증하면 테스트 신뢰도가 높아집니다.

♻️ 제안
         // then
         verify(mailSender, times(2)).send(any(MailMessage.class));
+        verify(memberRecommendationRepository, times(2)).save(any());
+        assertThat(mr1.getEmailSendStatus()).isEqualTo(EmailSendStatus.SENT);
+        assertThat(mr2.getEmailSendStatus()).isEqualTo(EmailSendStatus.SENT);

156-184: 이메일 없는 회원 테스트에서 save() 호출 검증을 추가하는 것을 권장합니다.

프로덕션 코드(sendEmail)에서 이메일이 없으면 markEmailAsFailed()memberRecommendationRepository.save(mr)를 호출합니다. 상태 검증은 되어 있지만, FAILED 상태가 실제로 DB에 영속되는지(save 호출) 검증하면 더 완전한 테스트가 됩니다.

♻️ 예시 (nullEmail_marksFailed)
         // then
         assertThat(mr.getEmailSendStatus()).isEqualTo(EmailSendStatus.FAILED);
         verify(mailSender, never()).send(any());
+        verify(memberRecommendationRepository).save(mr);
src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationService.java (1)

61-78: createManualRecommendation 메서드에서 RecommendationCreator의 트랜잭션 전파 방식에 따른 데이터 조회 확인이 필요합니다.

Line 71에서 memberRecommendationRepository.findByRecommendationId()로 방금 생성된 MemberRecommendation들을 조회합니다. 현재는 같은 트랜잭션 내에서 동작하므로 문제없지만, 이전 리뷰에서 제안된 대로 RecommendationCreator.create()REQUIRES_NEW를 적용하면 별도 트랜잭션에서 커밋된 데이터를 조회하게 됩니다.

REQUIRES_NEW 적용 시에도 커밋 후 조회이므로 정상 동작하겠지만, create() 메서드가 이미 생성한 MemberRecommendation 목록을 직접 반환하도록 변경하면 불필요한 DB 조회를 제거할 수 있습니다.

♻️ create()에서 MemberRecommendation 목록을 반환하는 방안

RecommendationCreator.create()Recommendation뿐만 아니라 생성된 MemberRecommendation 목록도 함께 반환하면(예: record나 Pair), findByRecommendationId 쿼리를 제거할 수 있습니다.

 // RecommendationCreator.java
-Recommendation create(Team team, RecommendationType type) {
+CreationResult create(Team team, RecommendationType type) {
     Recommendation recommendation = createRecommendation(team, type);
     List<Problem> problems = createRecommendationProblems(recommendation, team);
-    createMemberRecommendations(recommendation, team);
+    List<MemberRecommendation> memberRecommendations = createMemberRecommendations(recommendation, team);
     ...
-    return recommendation;
+    return new CreationResult(recommendation, memberRecommendations);
 }
 // RecommendationService.java
-Recommendation recommendation = recommendationCreator.create(team, RecommendationType.MANUAL);
-List<MemberRecommendation> memberRecommendations =
-        memberRecommendationRepository.findByRecommendationId(recommendation.getId());
+var result = recommendationCreator.create(team, RecommendationType.MANUAL);
+recommendationEmailService.send(result.memberRecommendations());
src/main/java/com/ryu/studyhelper/recommendation/RecommendationController.java (1)

29-29: @Validated가 더 이상 필요하지 않을 수 있습니다.

count 파라미터의 @Min/@Max 검증이 제거되면서 현재 이 컨트롤러에서 @Validated가 활성화하는 검증 대상이 없습니다. 향후 검증이 추가될 가능성을 고려하여 유지해도 무방하지만, 현재로서는 불필요합니다.

Comment on lines +43 to +52
Recommendation create(Team team, RecommendationType type) {
Recommendation recommendation = createRecommendation(team, type);
List<Problem> problems = createRecommendationProblems(recommendation, team);
createMemberRecommendations(recommendation, team);

log.info("팀 '{}' 추천 생성 완료 - 타입: {}, 문제: {}개",
team.getName(), type, problems.size());

return recommendation;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Recommendation 저장이 핸들 검증보다 먼저 수행됩니다.

create() 메서드 실행 순서:

  1. createRecommendation() → DB에 Recommendation 저장 (Line 44)
  2. createRecommendationProblems()recommendProblemsForTeam() → 핸들 검증 (Line 82-84)

인증된 핸들이 없으면 Recommendation이 이미 저장된 후에 예외가 발생합니다. 트랜잭션 롤백으로 정리되겠지만, 검증 로직을 create() 시작 부분으로 이동하면 불필요한 DB 쓰기를 방지하고 의도가 명확해집니다.

🛡️ 제안: 검증을 먼저 수행
 Recommendation create(Team team, RecommendationType type) {
+    validateHandlesExist(team);
     Recommendation recommendation = createRecommendation(team, type);
     List<Problem> problems = createRecommendationProblems(recommendation, team);
     createMemberRecommendations(recommendation, team);
     ...
 }

+private void validateHandlesExist(Team team) {
+    List<String> handles = teamMemberRepository.findHandlesByTeamId(team.getId());
+    if (handles.isEmpty()) {
+        log.warn("팀 '{}'에 인증된 핸들이 없습니다", team.getName());
+        throw new CustomException(CustomResponseStatus.NO_VERIFIED_HANDLE);
+    }
+}
🤖 Prompt for AI Agents
In
`@src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationCreator.java`
around lines 43 - 52, 현재 create(Team team, RecommendationType type)가 먼저
createRecommendation(...)로 DB에 Recommendation을 저장한 뒤
createRecommendationProblems(...)에서 recommendProblemsForTeam(...)을 통해 핸들 검증을 수행해
예외가 발생할 수 있습니다; 이 순서를 바꿔 create() 시작부에서 recommendProblemsForTeam(...)이나 새 검증
메서드(예: validateTeamHasAuthenticatedHandles(team) 또는
ensureValidatedHandles(team))를 호출해 인증된 핸들 유무를 확인하고 예외가 있을 경우 즉시 반환/예외 처리하여 DB에
Recommendation을 저장(createRecommendation(...))하거나
createMemberRecommendations(...)를 호출하기 전에 불필요한 쓰기를 방지하세요. 이 변경은
createRecommendationProblems(...), recommendProblemsForTeam(...) 호출 시점과
createRecommendation(...) 호출 순서를 뒤바꾸는 방식으로 적용하세요.

@ryuwldnjs ryuwldnjs merged commit a3559e8 into main Feb 14, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[REFACTOR] RecommendationService 책임 분리 + 레거시 정리

1 participant