Skip to content

Commit 17a67a0

Browse files
committed
✨feat: 자동 모임 삭제 기능을 추가[#175]
그룹 작업이 완료된 후 시작 시간으로부터 24시간이 지나면 자동으로 삭제하는 예약 작업을 구현합니다. 관련 데이터 및 파일 삭제를 포함한 삭제 프로세스를 처리하는 새로운 서비스를 도입합니다. 데이터 무결성을 보장하기 위해 그룹 업데이트 요청에 유효성 검사 제약 조건을 추가합니다.
1 parent b86b220 commit 17a67a0

File tree

7 files changed

+262
-7
lines changed

7 files changed

+262
-7
lines changed

src/main/java/team/wego/wegobackend/WegobackendApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import org.springframework.boot.SpringApplication;
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
55
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
6+
import org.springframework.scheduling.annotation.EnableScheduling;
67

8+
@EnableScheduling
79
@SpringBootApplication
810
@EnableJpaAuditing
911
public class WegobackendApplication {

src/main/java/team/wego/wegobackend/group/v2/application/dto/request/UpdateGroupV2Request.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
package team.wego.wegobackend.group.v2.application.dto.request;
22

33
import jakarta.validation.Valid;
4+
import jakarta.validation.constraints.Future;
5+
import jakarta.validation.constraints.FutureOrPresent;
6+
import jakarta.validation.constraints.Max;
7+
import jakarta.validation.constraints.Min;
8+
import jakarta.validation.constraints.NotNull;
49
import jakarta.validation.constraints.Size;
510
import java.time.LocalDateTime;
611
import java.util.List;
@@ -20,9 +25,14 @@ public record UpdateGroupV2Request(
2025
String location,
2126
String locationDetail,
2227

28+
@FutureOrPresent(message = "모임: 시작 시간은 현재 이후여야 합니다.")
2329
LocalDateTime startTime,
30+
31+
@Future(message = "모임: 종료 시간은 현재 이후여야 합니다.")
2432
LocalDateTime endTime,
2533

34+
@Min(value = 2, message = "모임: 최대 인원은 최소 2명 이상이어야 합니다.")
35+
@Max(value = 12, message = "모임: 최대 인원은 최대 12명 이하이어야 합니다.")
2636
Integer maxParticipants,
2737

2838
GroupV2Status status,
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package team.wego.wegobackend.group.v2.application.service;
2+
3+
import java.util.List;
4+
import lombok.RequiredArgsConstructor;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.springframework.context.ApplicationEventPublisher;
7+
import org.springframework.stereotype.Service;
8+
import org.springframework.transaction.annotation.Propagation;
9+
import org.springframework.transaction.annotation.Transactional;
10+
import org.springframework.transaction.support.TransactionSynchronization;
11+
import org.springframework.transaction.support.TransactionSynchronizationManager;
12+
import team.wego.wegobackend.group.v2.application.event.GroupDeletedEvent;
13+
import team.wego.wegobackend.group.v2.domain.entity.GroupUserV2Status;
14+
import team.wego.wegobackend.group.v2.domain.entity.GroupV2;
15+
import team.wego.wegobackend.group.v2.domain.entity.GroupV2Status;
16+
import team.wego.wegobackend.group.v2.domain.repository.GroupImageV2Repository;
17+
import team.wego.wegobackend.group.v2.domain.repository.GroupTagV2Repository;
18+
import team.wego.wegobackend.group.v2.domain.repository.GroupUserV2Repository;
19+
import team.wego.wegobackend.group.v2.domain.repository.GroupV2Repository;
20+
import team.wego.wegobackend.image.application.service.ImageUploadService;
21+
22+
@Slf4j
23+
@RequiredArgsConstructor
24+
@Service
25+
public class GroupV2AutoDeleteWorker {
26+
27+
private final GroupV2Repository groupV2Repository;
28+
private final GroupUserV2Repository groupUserV2Repository;
29+
private final GroupTagV2Repository groupTagV2Repository;
30+
private final GroupImageV2Repository groupImageV2Repository;
31+
32+
private final ImageUploadService imageUploadService;
33+
private final ApplicationEventPublisher eventPublisher;
34+
35+
// REQUIRES_NEW를 제대로 적용하려면 다른 빈(프록시)에서 호출
36+
@Transactional(propagation = Propagation.REQUIRES_NEW)
37+
public void deleteOneGroupAfter24h(Long groupId) {
38+
GroupV2 group = groupV2Repository.findById(groupId).orElse(null);
39+
if (group == null) {
40+
log.warn("[모임 자동삭제] 대상 모임을 찾을 수 없어 건너뜁니다. groupId={}", groupId);
41+
return;
42+
}
43+
44+
if (group.getDeletedAt() != null || group.getStatus() != GroupV2Status.FINISHED) {
45+
log.info("[모임 자동삭제] 현재 삭제 조건이 아니어서 건너뜁니다. groupId={} status={}",
46+
groupId, group.getStatus());
47+
return;
48+
}
49+
50+
// 삭제 전 알림을 위한 정보를 캡처
51+
final Long hostId = group.getHost().getId();
52+
final String hostNickName = group.getHost().getNickName();
53+
final String groupTitle = group.getTitle();
54+
55+
List<Long> attendeeIds = groupUserV2Repository.findUserIdsByGroupIdAndStatus(
56+
groupId, GroupUserV2Status.ATTEND
57+
).stream().filter(id -> !id.equals(hostId)).toList();
58+
59+
// 삭제 전에 S3 삭제 대상을 포착
60+
List<String> variantUrls = groupImageV2Repository.findAllVariantUrlsByGroupId(groupId);
61+
62+
log.info("[모임 자동삭제] 삭제 시작. groupId={} hostId={} 제목='{}' 참여자수={} S3파일수={}",
63+
groupId, hostId, groupTitle, attendeeIds.size(),
64+
(variantUrls == null ? 0 : variantUrls.size()));
65+
66+
// DB delete (same order as deleteHard)
67+
groupUserV2Repository.deleteByGroupId(groupId);
68+
groupTagV2Repository.deleteByGroupId(groupId);
69+
groupImageV2Repository.deleteVariantsByGroupId(groupId);
70+
groupImageV2Repository.deleteImagesByGroupId(groupId);
71+
groupV2Repository.delete(group);
72+
73+
registerAfterCommitS3Deletion(groupId, variantUrls);
74+
registerAfterCommitGroupDeletedEvent(groupId, hostId, hostNickName, groupTitle,
75+
attendeeIds);
76+
77+
log.info("[모임 자동삭제] DB 삭제 완료 및 커밋 후 작업 등록 완료. groupId={}", groupId);
78+
}
79+
80+
private void registerAfterCommitS3Deletion(Long groupId, List<String> variantUrls) {
81+
if (variantUrls == null || variantUrls.isEmpty()) {
82+
log.info("[모임 자동삭제][S3] 삭제할 이미지가 없습니다. groupId={}", groupId);
83+
return;
84+
}
85+
86+
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
87+
log.warn("[모임 자동삭제][S3] 트랜잭션 동기화가 없어 즉시 삭제합니다. groupId={} url개수={}",
88+
groupId, variantUrls.size());
89+
imageUploadService.deleteAllByUrls(variantUrls);
90+
return;
91+
}
92+
93+
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
94+
@Override
95+
public void afterCommit() {
96+
try {
97+
log.info("[모임 자동삭제][S3] 커밋 완료. S3 이미지 삭제 시작. groupId={} url개수={}",
98+
groupId, variantUrls.size());
99+
imageUploadService.deleteAllByUrls(variantUrls);
100+
log.info("[모임 자동삭제][S3] S3 이미지 삭제 완료. groupId={}", groupId);
101+
} catch (Exception e) {
102+
log.error("[모임 자동삭제][S3] S3 삭제 실패. groupId={} 원인={}",
103+
groupId, e.toString(), e);
104+
}
105+
}
106+
});
107+
}
108+
109+
private void registerAfterCommitGroupDeletedEvent(
110+
Long groupId,
111+
Long hostId,
112+
String hostNickName,
113+
String groupTitle,
114+
List<Long> attendeeIds
115+
) {
116+
if (attendeeIds == null || attendeeIds.isEmpty()) {
117+
log.info("[모임 자동삭제][알림] 알림 대상자가 없어 발행을 생략합니다. groupId={}", groupId);
118+
return;
119+
}
120+
121+
GroupDeletedEvent event = new GroupDeletedEvent(
122+
groupId, hostId, hostNickName, groupTitle, attendeeIds
123+
);
124+
125+
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
126+
log.warn("[모임 자동삭제][알림] 트랜잭션 동기화가 없어 즉시 발행합니다. groupId={} 대상자수={}",
127+
groupId, attendeeIds.size());
128+
eventPublisher.publishEvent(event);
129+
return;
130+
}
131+
132+
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
133+
@Override
134+
public void afterCommit() {
135+
log.info("[모임 자동삭제][알림] 커밋 완료. 삭제 알림 이벤트 발행. groupId={} hostId={} 대상자수={}",
136+
groupId, hostId, attendeeIds.size());
137+
eventPublisher.publishEvent(event);
138+
}
139+
});
140+
}
141+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package team.wego.wegobackend.group.v2.application.service;
2+
3+
import java.time.LocalDateTime;
4+
import java.util.List;
5+
import lombok.RequiredArgsConstructor;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.data.domain.PageRequest;
8+
import org.springframework.scheduling.annotation.Scheduled;
9+
import org.springframework.stereotype.Service;
10+
import org.springframework.transaction.annotation.Transactional;
11+
import team.wego.wegobackend.group.v2.domain.entity.GroupV2Status;
12+
import team.wego.wegobackend.group.v2.domain.repository.GroupV2Repository;
13+
14+
@Slf4j
15+
@RequiredArgsConstructor
16+
@Service
17+
public class GroupV2AutoMaintenanceService {
18+
19+
private final GroupV2Repository groupV2Repository;
20+
private final GroupV2AutoDeleteWorker autoDeleteWorker;
21+
22+
private static final List<GroupV2Status> FINISH_TARGETS =
23+
List.of(GroupV2Status.RECRUITING, GroupV2Status.FULL, GroupV2Status.CLOSED);
24+
25+
private static final int DELETE_BATCH_SIZE = 200;
26+
27+
// 시작 시간에 도달하면 그룹을 자동으로 완료 상태로 이동
28+
@Transactional
29+
@Scheduled(cron = "0 */1 * * * *", zone = "Asia/Seoul") // 1분마다
30+
public void autoFinishByStartTime() {
31+
LocalDateTime now = LocalDateTime.now();
32+
int updated = groupV2Repository.bulkFinishByStartTime(now, FINISH_TARGETS);
33+
34+
if (updated > 0) {
35+
log.info("[모임 자동종료] 시작시간 도달로 FINISHED 상태 변경 완료. 변경건수={}", updated);
36+
}
37+
}
38+
39+
// 완료된 그룹은 시작 시간으로부터 24시간이 지난 후 영구 삭제
40+
@Scheduled(cron = "30 */5 * * * *", zone = "Asia/Seoul") // 5분 간격, 30초 간격
41+
// @Scheduled(cron = "30 */1 * * * *", zone = "Asia/Seoul") // 1분 간격, 30초 오프셋
42+
public void autoHardDeleteFinishedAfter24h() {
43+
LocalDateTime threshold = LocalDateTime.now().minusHours(24);
44+
// LocalDateTime threshold = LocalDateTime.now().minusMinutes(1);
45+
46+
while (true) {
47+
List<Long> groupIds = groupV2Repository.findFinishedExpiredGroupIdsByStartTime(
48+
threshold, PageRequest.of(0, DELETE_BATCH_SIZE)
49+
);
50+
51+
if (groupIds.isEmpty()) {
52+
return;
53+
}
54+
55+
log.info("[모임 자동삭제] 삭제 대상 조회 완료. 대상건수={} 기준시각={}",
56+
groupIds.size(), threshold);
57+
58+
for (Long groupId : groupIds) {
59+
try {
60+
autoDeleteWorker.deleteOneGroupAfter24h(groupId);
61+
} catch (Exception e) {
62+
log.error("[모임 자동삭제] 삭제 처리 실패. groupId={} 원인={}",
63+
groupId, e.toString(), e);
64+
}
65+
}
66+
67+
log.info("[모임 자동삭제] 배치 처리 완료. 처리대상건수={}", groupIds.size());
68+
}
69+
}
70+
}
71+

src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2Service.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,8 @@ public CreateGroupV2Response create(Long userId, CreateGroupV2Request request) {
250250
// 이벤트 발행
251251
log.info("[GROUP] created. groupId={}, hostId={}", saved.getId(), host.getId());
252252
eventPublisher.publishEvent(new GroupCreatedEvent(saved.getId(), host.getId()));
253-
log.info("[GROUP] published GroupCreatedEvent. groupId={}, hostId={}", saved.getId(), host.getId());
254-
253+
log.info("[GROUP] published GroupCreatedEvent. groupId={}, hostId={}", saved.getId(),
254+
host.getId());
255255

256256
return CreateGroupV2Response.from(saved, host);
257257
}
@@ -268,5 +268,4 @@ public GetGroupV2Response getGroup(Long userId, Long groupId) {
268268

269269
return GetGroupV2Response.of(group, images, users, userId);
270270
}
271-
272271
}

src/main/java/team/wego/wegobackend/group/v2/domain/repository/GroupV2Repository.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
package team.wego.wegobackend.group.v2.domain.repository;
22

33

4+
import java.time.LocalDateTime;
5+
import java.util.List;
46
import java.util.Optional;
7+
import org.springframework.data.domain.Pageable;
58
import org.springframework.data.jpa.repository.JpaRepository;
9+
import org.springframework.data.jpa.repository.Modifying;
610
import org.springframework.data.jpa.repository.Query;
711
import org.springframework.data.repository.query.Param;
812
import team.wego.wegobackend.group.v2.domain.entity.GroupV2;
13+
import team.wego.wegobackend.group.v2.domain.entity.GroupV2Status;
914

1015
public interface GroupV2Repository extends JpaRepository<GroupV2, Long> {
1116

@@ -18,4 +23,30 @@ public interface GroupV2Repository extends JpaRepository<GroupV2, Long> {
1823
where g.id = :groupId
1924
""")
2025
Optional<GroupV2> findGroupWithHostAndTags(@Param("groupId") Long groupId);
26+
27+
@Modifying(clearAutomatically = true, flushAutomatically = true)
28+
@Query("""
29+
update GroupV2 g
30+
set g.status = 'FINISHED'
31+
where g.deletedAt is null
32+
and g.status in :targets
33+
and g.startTime <= :now
34+
""")
35+
int bulkFinishByStartTime(
36+
@Param("now") LocalDateTime now,
37+
@Param("targets") List<GroupV2Status> targets
38+
);
39+
40+
@Query("""
41+
select g.id
42+
from GroupV2 g
43+
where g.deletedAt is null
44+
and g.status = 'FINISHED'
45+
and g.startTime <= :threshold
46+
order by g.id asc
47+
""")
48+
List<Long> findFinishedExpiredGroupIdsByStartTime(
49+
@Param("threshold") LocalDateTime threshold,
50+
Pageable pageable
51+
);
2152
}

src/main/java/team/wego/wegobackend/notification/application/dispatcher/NotificationDispatcher.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,6 @@ public void dispatch(
3333
return;
3434
}
3535

36-
37-
3836
// 저장 결과를 받아서 "ID 확정된 엔티티"로 SSE 전송
3937
List<Notification> saved = notificationRepository.saveAll(notifications);
4038
notificationRepository.flush();
@@ -45,13 +43,16 @@ public void dispatch(
4543
int sent = 0;
4644
int noEmitter = 0;
4745

48-
4946
for (Notification n : saved) {
5047
Long receiverId = n.getReceiver().getId();
5148
boolean ok = sseEmitterService.sendNotificationIfConnected(
5249
receiverId, NotificationEvent.of(n, actor, group)
5350
);
54-
if (ok) sent++; else noEmitter++;
51+
if (ok) {
52+
sent++;
53+
} else {
54+
noEmitter++;
55+
}
5556
}
5657
log.info("[NOTI][DISPATCH] sseSent={} noEmitter={}", sent, noEmitter);
5758
}

0 commit comments

Comments
 (0)