Skip to content
62 changes: 62 additions & 0 deletions src/main/java/at/mateball/domain/group/core/GroupExecutorV3.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package at.mateball.domain.group.core;

import at.mateball.domain.chatting.core.Chatting;
import at.mateball.domain.chatting.core.service.ChattingV2Service;
import at.mateball.domain.gameinformation.core.GameInformation;
import at.mateball.domain.group.scheduler.MatchSchedule;
import at.mateball.domain.group.scheduler.ScheduleType;
import at.mateball.domain.group.scheduler.repository.SchedulerRepository;
import at.mateball.domain.groupmember.GroupMemberStatus;
import at.mateball.domain.groupmember.core.GroupMember;
import at.mateball.domain.user.core.User;
import at.mateball.exception.BusinessException;
import at.mateball.exception.code.BusinessErrorCode;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;

@Component
public class GroupExecutorV3 {

private final EntityManager entityManager;
private final ChattingV2Service chattingV2Service;
private final SchedulerRepository schedulerRepository;

public GroupExecutorV3(EntityManager entityManager, ChattingV2Service chattingV2Service, SchedulerRepository schedulerRepository) {
this.entityManager = entityManager;
this.chattingV2Service = chattingV2Service;
this.schedulerRepository = schedulerRepository;
}

@Transactional
public Long createGroup(Long userId, Long gameId, boolean isGroup) {
User user = entityManager.find(User.class, userId);
if (user == null) {
throw new BusinessException(BusinessErrorCode.USER_NOT_FOUND);
}

GameInformation game = entityManager.find(GameInformation.class, gameId);
LocalDateTime gameDateTime = LocalDateTime.of(game.getGameDate(), game.getGameTime());

Chatting chatting = chattingV2Service.assignChatting();
if (chatting == null) {
throw new BusinessException(BusinessErrorCode.CHATTING_NOT_FOUND);
}

Group group = Group.create(user, game, isGroup);
group.assignChatting(chatting);

entityManager.persist(group);
entityManager.flush();

GroupMember leader = GroupMember.leader(user, group, GroupMemberStatus.PENDING_REQUEST.getValue());
entityManager.persist(leader);

schedulerRepository.save(new MatchSchedule(group, gameDateTime.minusMinutes(1), ScheduleType.FAIL));
schedulerRepository.save(new MatchSchedule(group, gameDateTime, ScheduleType.COMPLETE));
Comment on lines +57 to +58
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

FAIL과 COMPLETE 스케줄 동시 실행 가능성

FAIL 스케줄은 gameDateTime - 1분, COMPLETE 스케줄은 gameDateTime에 등록됩니다. 스케줄러가 1분 간격으로 실행되므로, 스케줄러 실행 시점에 따라 두 스케줄이 동시에 due 상태가 될 수 있습니다. SchedulerRepositoryImpl.findAllDueSchedules()에 ORDER BY가 없으면 처리 순서가 보장되지 않아 의도치 않은 결과가 발생할 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/at/mateball/domain/group/core/GroupExecutorV3.java` around
lines 57 - 58, The two MatchSchedule entries saved in GroupExecutorV3
(MatchSchedule(..., gameDateTime.minusMinutes(1), ScheduleType.FAIL) and
MatchSchedule(..., gameDateTime, ScheduleType.COMPLETE)) can become due at the
same scheduler run and lead to nondeterministic processing because
SchedulerRepositoryImpl.findAllDueSchedules() has no ORDER BY; update the
repository query to enforce deterministic ordering (e.g., ORDER BY scheduledAt
ASC, scheduleType ASC or use a CASE to prioritize ScheduleType.FAIL before
COMPLETE) so due schedules are returned in a stable, intended order, or
alternatively adjust the FAIL timing creation in GroupExecutorV3 to guarantee at
least one scheduler interval gap—choose the repository ORDER BY approach and add
the ordering to SchedulerRepositoryImpl.findAllDueSchedules().


return group.getId();
}
}
8 changes: 8 additions & 0 deletions src/main/java/at/mateball/domain/group/core/GroupStatus.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ public static GroupStatus fromCode(String label) {
.orElseThrow(() -> new BusinessException(BusinessErrorCode.BAD_REQUEST_ENUM));
}

public static String labelOf(int value) {
return Arrays.stream(values())
.filter(status -> status.value == value)
.map(status -> status.label)
.findFirst()
.orElseThrow(() -> new BusinessException(BusinessErrorCode.BAD_REQUEST_ENUM));
}

public String toResponseLabel() {
return switch (this) {
case PENDING -> "그룹원 모집중";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package at.mateball.domain.group.scheduler;

import at.mateball.domain.group.core.Group;
import at.mateball.domain.group.core.repository.GroupRepository;
import at.mateball.domain.groupmember.core.repository.GroupMemberRepository;
import at.mateball.exception.BusinessException;
import at.mateball.exception.code.BusinessErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class GroupMatchCompleteService {
private static final int MIN_PARTICIPANT_FOR_COMPLETE = 2;

private final GroupRepository groupRepository;
private final GroupMemberRepository groupMemberRepository;

@Transactional
public void validateCompleteStatus(Long groupId) {
Group group = groupRepository.findById(groupId)
.orElseThrow(() -> new BusinessException(BusinessErrorCode.GROUP_NOT_FOUND));

int memberCount = groupMemberRepository.countGroupMember(groupId).count();

if ((group.isGroup() && memberCount >= MIN_PARTICIPANT_FOR_COMPLETE)) {
groupMemberRepository.updateAllStatusComplete(groupId);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package at.mateball.domain.group.scheduler;

import at.mateball.domain.group.core.repository.GroupRepository;
import at.mateball.domain.groupmember.core.repository.GroupMemberRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -12,10 +13,14 @@
@Service
@Slf4j
public class GroupMatchFailService {
private static final int MIN_PARTICIPANT_FOR_COMPLETE = 2;

private final GroupRepository groupRepository;
private final GroupMemberRepository groupMemberRepository;

public GroupMatchFailService(GroupRepository groupRepository) {
public GroupMatchFailService(GroupRepository groupRepository, GroupMemberRepository groupMemberRepository) {
this.groupRepository = groupRepository;
this.groupMemberRepository = groupMemberRepository;
}

@Transactional
Expand Down Expand Up @@ -44,4 +49,12 @@ private LocalDate calculateTargetDate(LocalDate today) {

return today.plusDays(2);
}

public void validateFailStatus(Long groupId) {
int participantCount = groupMemberRepository.countGroupMember(groupId).count();

if (participantCount < MIN_PARTICIPANT_FOR_COMPLETE) {
groupMemberRepository.updateAllStatusFail(groupId);
}
}
Comment on lines +53 to +59
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 20, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

@Transactional 어노테이션 누락 및 isGroup 조건 검사 누락

두 가지 문제가 있습니다:

  1. @transactional 누락: GroupMatchCompleteService.validateCompleteStatus()@Transactional이 있지만, 이 메서드에는 없습니다. 일관성을 위해 추가해야 합니다.

  2. isGroup 조건 누락: GroupMatchCompleteService에서는 group.isGroup() && memberCount >= MIN_PARTICIPANT_FOR_COMPLETE 조건을 검사하지만, 이 메서드에서는 isGroup 검사가 없습니다. 이로 인해 1:1 직접 매칭(non-group)도 실패 처리될 수 있습니다.

🐛 수정 제안
+@Transactional
 public void validateFailStatus(Long groupId) {
+    Group group = groupRepository.findById(groupId)
+            .orElseThrow(() -> new BusinessException(BusinessErrorCode.GROUP_NOT_FOUND));
+
     int participantCount = groupMemberRepository.countGroupMember(groupId).count();
 
-    if (participantCount < MIN_PARTICIPANT_FOR_COMPLETE) {
+    if (group.isGroup() && participantCount < MIN_PARTICIPANT_FOR_COMPLETE) {
         groupMemberRepository.updateAllStatusFail(groupId);
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/at/mateball/domain/group/scheduler/GroupMatchFailService.java`
around lines 53 - 59, 메서드 validateFailStatus(Long groupId)에 `@Transactional`
애노테이션을 추가하고, 1:1 매칭을 잘못 처리하지 않도록 그룹이 실제로 그룹인지 확인하도록 수정하세요:
GroupMatchFailService.validateFailStatus에서 groupId로 그룹을 로드(예:
groupRepository.findById(groupId) 또는 기존 그룹 조회 메서드 사용), Optional 처리 후 그룹의
isGroup()이 true인 경우에만 기존 로직(participantCount 계산 및
groupMemberRepository.updateAllStatusFail(groupId))을 실행하도록 변경하고, 그룹이 없을 때의 예외/무시
처리도 일관되게 적용하세요.

Copy link
Copy Markdown
Collaborator Author

@hyeeum hyeeum Mar 20, 2026

Choose a reason for hiding this comment

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

맞습니다, 일대일과 그룹매칭 모두 MIN_PARTICIPANT_FOR_COMPLETE 이하일때 fail 로직이 실행되어야 하므로, 그룹여부는 검증하지 않았습니다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The PR description image shows test execution results for the MatchScheduler, with FAIL and COMPLETE scenarios running successfully — including cases for both a solo creator and a creator with a pending requester being marked as failed, and a group with two participants marked as complete.

@hyeeum, 설명 감사합니다! 일대일 매칭과 그룹 매칭 모두 참여자 수가 부족할 경우 동일하게 실패 처리되어야 한다는 점은 이해했습니다. isGroup 검사를 생략한 것은 의도된 설계로 철회합니다.

다만, @Transactional 누락 이슈는 여전히 유효합니다. validateFailStatus()updateAllStatusFail()을 호출하여 DB 쓰기 작업을 수행하는데, @Transactional이 없으면 트랜잭션 경계가 보장되지 않습니다. (참고로 GroupMatchCompleteService.validateCompleteStatus()에는 @Transactional이 있습니다.) 추가를 검토해 주세요.

`@Transactional`
public void validateFailStatus(Long groupId) {
    int participantCount = groupMemberRepository.countGroupMember(groupId).count();

    if (participantCount < MIN_PARTICIPANT_FOR_COMPLETE) {
        groupMemberRepository.updateAllStatusFail(groupId);
    }
}

✏️ Learnings added
Learnt from: hyeeum
Repo: MATEBALL/MATEBALL-SERVER PR: 250
File: src/main/java/at/mateball/domain/group/scheduler/GroupMatchFailService.java:53-59
Timestamp: 2026-03-20T10:10:35.829Z
Learning: In MATEBALL-SERVER, `GroupMatchFailService.validateFailStatus()` intentionally does NOT check `isGroup()` before running fail logic. Both 1:1 and group matches are treated the same for failure: if participantCount < MIN_PARTICIPANT_FOR_COMPLETE (2), the match is marked as failed. The `isGroup` check in `GroupMatchCompleteService.validateCompleteStatus()` is specific to the complete path only.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: hyeeum
Repo: MATEBALL/MATEBALL-SERVER PR: 238
File: src/main/java/at/mateball/domain/group/core/validator/DateValidator.java:25-33
Timestamp: 2026-03-17T04:16:00.534Z
Learning: In MATEBALL-SERVER, the "+2 days" restriction (`BAD_REQUEST_DATE`) in `DateValidator.validate()` (src/main/java/at/mateball/domain/group/core/validator/DateValidator.java) is intentionally commented out as of PR `#238` because this exception condition is currently not applicable across any caller (including GroupService.validateRequest and GameInformationService.getGameInformation), not just the V3 matching creation API. Do not flag the commented-out `BAD_REQUEST_DATE` logic as a missing or improperly scoped validation.

Learnt from: hyeeum
Repo: MATEBALL/MATEBALL-SERVER PR: 238
File: src/main/java/at/mateball/domain/group/core/GroupExecutorV3.java:31-33
Timestamp: 2026-03-17T04:15:08.936Z
Learning: In MATEBALL-SERVER (GroupV3Service + GroupExecutorV3), game existence validation is performed in the service layer before calling GroupExecutorV3.createGroup(). Specifically, groupV3RepositoryCustom.getMatchValidationInfo(userId, gameId) is called first, and if it returns null, a BusinessException(BusinessErrorCode.GAME_NOT_FOUND) is thrown — so GroupExecutorV3 does not need its own null check for GameInformation.

Learnt from: hyeeum
Repo: MATEBALL/MATEBALL-SERVER PR: 238
File: src/main/java/at/mateball/domain/group/core/service/GroupV3Service.java:224-224
Timestamp: 2026-03-17T04:15:33.223Z
Learning: In MATEBALL-SERVER, the Monday restriction (`BAD_REQUEST_MONDAY`) and "+2 days" restriction (`BAD_REQUEST_DATE`) in `DateValidator` (src/main/java/at/mateball/domain/group/core/validator/DateValidator.java) are intentionally commented out as of PR `#238`, because these exception conditions are currently not applicable to the V3 matching creation API. Only the past-date check (`BAD_REQUEST_PAST`) remains active. Do not flag the commented-out rules as missing validation.

Learnt from: hyeeum
Repo: MATEBALL/MATEBALL-SERVER PR: 235
File: src/main/java/at/mateball/domain/group/core/service/GroupV3Service.java:215-234
Timestamp: 2026-03-15T20:04:35.817Z
Learning: In the MATEBALL-SERVER project, the group leader (생성자) is also added as a record in the groupMember table. Therefore, when implementing chatting access checks in GroupV3Service.getChattingUrl(), memberStatus will not be null for the leader, and there is no need to add a separate leader-check using leaderId before calling GroupMemberStatus.from(data.memberStatus()).

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package at.mateball.domain.group.scheduler;

import at.mateball.domain.group.core.Group;
import jakarta.persistence.*;
import lombok.Getter;

import java.time.LocalDateTime;

@Entity
@Getter
@Table(name = "match_schedule")
public class MatchSchedule {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "group_id", nullable = false)
private Group group;
Comment on lines +17 to +19
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Group 삭제 로직 확인
rg -n "deleteGroup|delete.*Group" --type=java -C3

Repository: MATEBALL/MATEBALL-SERVER

Length of output: 745


🏁 Script executed:

# Find and examine the Group entity definition
fd -t f "Group.java" --type=file | grep -E "domain/group|entity" | head -5

Repository: MATEBALL/MATEBALL-SERVER

Length of output: 122


🏁 Script executed:

# Search for Group entity class definition
rg -n "class Group" --type=java -A 30 | head -100

Repository: MATEBALL/MATEBALL-SERVER

Length of output: 11902


🏁 Script executed:

# Check for any cascade or orphan delete configurations related to MatchSchedule
rg -n "MatchSchedule" --type=java -B 2 -A 2

Repository: MATEBALL/MATEBALL-SERVER

Length of output: 13209


🏁 Script executed:

# Look for deletion logic related to MatchSchedule
rg -n "delete.*MatchSchedule|MatchSchedule.*delete" --type=java -C 3

Repository: MATEBALL/MATEBALL-SERVER

Length of output: 50


🏁 Script executed:

cat -n src/main/java/at/mateball/domain/group/core/Group.java

Repository: MATEBALL/MATEBALL-SERVER

Length of output: 1821


🏁 Script executed:

# Check if there's any explicit cleanup logic for MatchSchedule deletion in services
rg -n "delete|remove" src/main/java/at/mateball/domain/group/scheduler/ --type=java -B 2 -A 2

Repository: MATEBALL/MATEBALL-SERVER

Length of output: 50


🏁 Script executed:

# Check MatchScheduler implementation to understand how it handles missing groups
cat -n src/main/java/at/mateball/domain/group/scheduler/MatchScheduler.java

Repository: MATEBALL/MATEBALL-SERVER

Length of output: 1798


🏁 Script executed:

# Check how lazy loading behaves when Group is deleted - look for FK constraint details
rg -n "foreign key|FOREIGN KEY|constraint" --type=sql --type=ddl 2>/dev/null || echo "No SQL files found"

Repository: MATEBALL/MATEBALL-SERVER

Length of output: 86


🏁 Script executed:

# Look for any test that validates Group deletion behavior
rg -n "deleteAllByLeaderId|testDelete|Group.*delete" src/test --type=java -B 2 -A 5

Repository: MATEBALL/MATEBALL-SERVER

Length of output: 50


🏁 Script executed:

# Check if there are any migration files or schema definitions that show FK constraints
fd -type f -name "*.sql" -o -name "*schema*" -o -name "*migration*" | head -20

Repository: MATEBALL/MATEBALL-SERVER

Length of output: 238


🏁 Script executed:

# Look for any repository delete methods related to Group
rg -n "delete" src/main/java/at/mateball/domain/group/core/repository --type=java -B 1 -A 3

Repository: MATEBALL/MATEBALL-SERVER

Length of output: 872


🏁 Script executed:

# Check GroupMatchFailService to see how it handles the Group
cat -n src/main/java/at/mateball/domain/group/scheduler/GroupMatchFailService.java | head -50

Repository: MATEBALL/MATEBALL-SERVER

Length of output: 2216


🏁 Script executed:

# Check if there are any database migration files or schema files
find . -name "*.sql" -o -name "*.ddl" -o -name "*schema*" 2>/dev/null | grep -v node_modules | head -20

Repository: MATEBALL/MATEBALL-SERVER

Length of output: 50


🏁 Script executed:

# Look at the test file to see what happens with orphaned schedules
cat -n src/test/java/at/mateball/scheduler/MatchSchedulerIntegrationTest.java | head -100

Repository: MATEBALL/MATEBALL-SERVER

Length of output: 3981


🏁 Script executed:

# Check how the scheduler actually uses the group object
rg -n "getGroup\(\)" src/main/java/at/mateball/domain/group/scheduler/ --type=java -B 1 -A 1

Repository: MATEBALL/MATEBALL-SERVER

Length of output: 1025


🏁 Script executed:

# Check if there's any validation or FK constraint setup in persistence configuration
rg -n "hibernate|JPA|constraint|foreign" src/main/resources --type=properties -i

Repository: MATEBALL/MATEBALL-SERVER

Length of output: 102


🏁 Script executed:

# Look for any additional methods in GroupMatchFailService that might handle cleanup
cat -n src/main/java/at/mateball/domain/group/scheduler/GroupMatchFailService.java

Repository: MATEBALL/MATEBALL-SERVER

Length of output: 2578


🏁 Script executed:

# Look for any usage of deleteAllByLeaderId to understand when/if groups are actually deleted
rg -n "deleteAllByLeaderId" --type=java -B 2 -A 2

Repository: MATEBALL/MATEBALL-SERVER

Length of output: 1029


🏁 Script executed:

# Check application.yml or application.properties for JPA/Hibernate settings
find . -name "application*.yml" -o -name "application*.properties" 2>/dev/null | xargs cat 2>/dev/null | grep -i "hibernate\|jpa\|constraint" | head -20

Repository: MATEBALL/MATEBALL-SERVER

Length of output: 140


🏁 Script executed:

# Check if there's any explicit handling in MatchScheduler for deleted groups
cat -n src/main/java/at/mateball/domain/group/scheduler/MatchScheduler.java

Repository: MATEBALL/MATEBALL-SERVER

Length of output: 1798


그룹 삭제 시 MatchSchedule 레코드가 orphan으로 남을 수 있는 설계 이슈

Group 엔티티에 MatchSchedule에 대한 @OneToMany 관계와 cascade delete 설정이 없습니다. Group이 삭제될 때(예: GroupRepository.deleteAllByLeaderId()), 해당 그룹을 참조하는 MatchSchedule 레코드는 고아 레코드로 남게 됩니다.

MatchScheduler.run()이 이러한 orphan 레코드를 처리하려 할 때 s.getGroup().getId()를 호출하면 LAZY 로딩으로 인해 null 참조 또는 제약 조건 위반 예외가 발생할 수 있습니다. 현재 try-catch 블록이 이를 포착하지만, 근본적으로는 Group 엔티티에서 @OneToMany(cascade = CascadeType.DELETE) 관계를 추가하거나 MatchSchedule 삭제 로직을 명시적으로 구현해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/at/mateball/domain/group/scheduler/MatchSchedule.java` around
lines 17 - 19, Group 삭제 시 MatchSchedule 엔티티가 orphan으로 남아 LAZY 로딩에서
s.getGroup().getId() 호출 시 NPE/제약 위반이 발생할 수 있으므로 MatchSchedule-Group 연관관계를 정리하세요:
Group 엔티티에 MatchSchedule에 대한 `@OneToMany`(mappedBy="group", cascade =
CascadeType.REMOVE 또는 CascadeType.ALL, orphanRemoval = true) 설정을 추가하거나,
GroupRepository.deleteAllByLeaderId() 호출 전에 MatchScheduleRepository에서 해당 그룹의
스케줄을 명시적으로 삭제하는 로직을 구현해 MatchScheduler.run()이 null 그룹을 참조하지 않도록 하세요 (참조할 식별자:
class MatchSchedule, field group, method MatchScheduler.run(), method
GroupRepository.deleteAllByLeaderId()).


@Column(nullable = false)
private LocalDateTime executeTime;

@Column(nullable = false)
@Enumerated(EnumType.STRING)
private ScheduleType type;

@Column(nullable = false)
private boolean executed = false;

protected MatchSchedule() {
}

public MatchSchedule(Group group, LocalDateTime executeTime, ScheduleType type) {
this.group = group;
this.executeTime = executeTime;
this.type = type;
this.executed = false;
}

public void markExecuted() {
this.executed = true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package at.mateball.domain.group.scheduler;

import at.mateball.domain.group.scheduler.repository.SchedulerRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;

@Component
@RequiredArgsConstructor
@Slf4j
public class MatchScheduler {

private final SchedulerRepository repository;
private final GroupMatchFailService matchFailService;
private final GroupMatchCompleteService matchCompleteService;

@Scheduled(cron = "0 */1 * * * *")
@Transactional
public void run() {
log.info("매칭 상태 업데이트 스케줄러 호출 완료");

LocalDateTime now = LocalDateTime.now();
List<MatchSchedule> targets = repository.findAllDueSchedules(now);

for (MatchSchedule s : targets) {
try {
if (s.getType().isFail()) {
matchFailService.validateFailStatus(s.getGroup().getId());
} else {
matchCompleteService.validateCompleteStatus(s.getGroup().getId());
}

s.markExecuted();

} catch (Exception e) {
log.error("스케줄 처리 실패 groupId={}", s.getGroup().getId(), e);
}
Comment on lines +30 to +42
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

예외 발생 시 스케줄 재처리 동작 명확화 필요

현재 구조에서 예외 발생 시 markExecuted()가 호출되지 않아 다음 스케줄러 실행 시 재처리됩니다. 이 동작이 의도된 것인지, 또는 실패한 스케줄도 executed로 마킹하고 별도 실패 처리를 해야 하는지 확인이 필요합니다. 의도된 재시도라면 무한 재시도 방지를 위한 retry count나 실패 상태 추가를 권장합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/at/mateball/domain/group/scheduler/MatchScheduler.java` around
lines 30 - 42, The current loop in MatchScheduler leaves failed schedules
unmarked causing indefinite reprocessing; update behavior by adding a retry
mechanism and explicit failure marking: extend MatchSchedule with fields
retryCount and maxRetries (and a markFailed()/setFailedReason() method),
increment retryCount when matchFailService.validateFailStatus(...) or
matchCompleteService.validateCompleteStatus(...) throws, and only call
s.markExecuted() on success or when retryCount > maxRetries; on final failure
call s.markFailed(...) (or set failed flag/reason) and log accordingly. Ensure
MatchScheduler references these symbols (MatchSchedule.retryCount/maxRetries,
markFailed, markExecuted) and persist the updated state so retries stop after
maxRetries.

}
}
}
15 changes: 15 additions & 0 deletions src/main/java/at/mateball/domain/group/scheduler/ScheduleType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package at.mateball.domain.group.scheduler;

public enum ScheduleType {

FAIL,
COMPLETE;

public boolean isFail() {
return this == FAIL;
}

public boolean isComplete() {
return this == COMPLETE;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package at.mateball.domain.group.scheduler.repository;

import at.mateball.domain.group.scheduler.MatchSchedule;

import java.time.LocalDateTime;
import java.util.List;

public interface SchedulerCustom {
void save(MatchSchedule schedule);

List<MatchSchedule> findAllDueSchedules(LocalDateTime now);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package at.mateball.domain.group.scheduler.repository;

import at.mateball.domain.group.core.Group;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface SchedulerRepository extends JpaRepository<Group, Long>, SchedulerCustom {
Comment on lines +7 to +8
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

치명적 버그: JpaRepository 제네릭 타입이 잘못 선언되었습니다.

SchedulerRepositoryJpaRepository<Group, Long>을 상속하고 있지만, 실제로는 MatchSchedule 엔티티를 관리합니다. GroupExecutorV3.java(Line 57-58)와 테스트 코드에서 schedulerRepository.save(new MatchSchedule(...))를 호출하고 있어, 런타임에 타입 불일치로 인한 오류가 발생합니다.

🐛 수정 제안
-import at.mateball.domain.group.core.Group;
+import at.mateball.domain.group.scheduler.MatchSchedule;
 import org.springframework.data.jpa.repository.JpaRepository;
 import org.springframework.stereotype.Repository;
 
 `@Repository`
-public interface SchedulerRepository extends JpaRepository<Group, Long>, SchedulerCustom {
+public interface SchedulerRepository extends JpaRepository<MatchSchedule, Long>, SchedulerCustom {
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/at/mateball/domain/group/scheduler/repository/SchedulerRepository.java`
around lines 7 - 8, The repository generic is wrong: SchedulerRepository
currently extends JpaRepository<Group, Long> but it actually persists
MatchSchedule instances; change the generic to JpaRepository<MatchSchedule,
Long> and add/import the MatchSchedule entity so save/load calls from
SchedulerRepository (and usages in GroupExecutorV3 and tests that call
schedulerRepository.save(new MatchSchedule(...))) type-check and work at
runtime; keep the SchedulerCustom extension unchanged.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package at.mateball.domain.group.scheduler.repository;

import at.mateball.domain.group.scheduler.MatchSchedule;
import at.mateball.domain.group.scheduler.QMatchSchedule;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;

import java.time.LocalDateTime;
import java.util.List;

public class SchedulerRepositoryImpl implements SchedulerCustom {
private final JPAQueryFactory queryFactory;
private final EntityManager em;

public SchedulerRepositoryImpl(JPAQueryFactory queryFactory, EntityManager em) {
this.queryFactory = queryFactory;
this.em = em;
}

@Override
public void save(MatchSchedule schedule) {
em.persist(schedule);
}

@Override
public List<MatchSchedule> findAllDueSchedules(LocalDateTime now) {
QMatchSchedule matchSchedule = QMatchSchedule.matchSchedule;

return queryFactory
.selectFrom(matchSchedule)
.where(
matchSchedule.executed.isFalse(),
matchSchedule.executeTime.loe(now)
)
.fetch();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ public static GroupMemberStatus from(int value) {
.orElseThrow(() -> new BusinessException(BusinessErrorCode.BAD_REQUEST_ENUM));
}

public static String labelOf(int value) {
return Arrays.stream(values())
.filter(status -> status.value == value)
.map(status -> status.label)
.findFirst()
.orElseThrow(() -> new BusinessException(BusinessErrorCode.BAD_REQUEST_ENUM));
}

public String toResponseLabel() {
return switch (this) {
case AWAITING_APPROVAL -> "수락 대기 중";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,9 @@ public interface GroupMemberRepositoryCustom {

Optional<Long> findApprovedRequesterUserId(Long groupId);

Optional<Long> findMatchedRequesterUserId(Long groupId);}
Optional<Long> findMatchedRequesterUserId(Long groupId);

void updateAllStatusFail(Long groupId);

void updateAllStatusComplete(Long groupId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -841,4 +841,34 @@ public Optional<Long> findMatchedRequesterUserId(Long groupId) {

return Optional.ofNullable(requesterId);
}

@Override
public void updateAllStatusFail(Long groupId){
queryFactory
.update(groupMember)
.set(groupMember.status, GroupMemberStatus.MATCH_FAILED.getValue())
.where(groupMember.group.id.eq(groupId))
.execute();

queryFactory
.update(group)
.set(group.status, GroupStatus.FAILED.getValue())
.where(group.id.eq(groupId))
.execute();
}

@Override
public void updateAllStatusComplete(Long groupId){
queryFactory
.update(groupMember)
.set(groupMember.status, GroupMemberStatus.MATCHED.getValue())
.where(groupMember.group.id.eq(groupId))
.execute();

queryFactory
.update(group)
.set(group.status, GroupStatus.COMPLETED.getValue())
.where(group.id.eq(groupId))
.execute();
}
}
Loading