From be1a4662624d60d6474adb4fb38f1900dd1cb2d8 Mon Sep 17 00:00:00 2001 From: yoon6yo Date: Sun, 5 Jul 2026 21:40:36 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EC=A1=B4=20=EB=B6=88=EC=9D=BC=EC=B9=98=20?= =?UTF-8?q?=EB=B0=8F=20LazyInitializationException=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [H-6] LocalDateTime.now() → LocalDateTime.now(ZoneId.of("Asia/Seoul"))로 수정 (RequestSchedulerService, UserSchedulerService 모두 적용) UTC 컨테이너 환경에서 KST cron과 최대 9시간 오차 발생하던 버그 해결 - [H-7] UserLifecycleTransactionalService 신규 도입 processInactiveUsers()에서 user.getRequests() 접근 시 트랜잭션 없어 LazyInitializationException 발생하던 문제를 유저별 독립 @Transactional로 해결 동시에 H-11 위험(단일 트랜잭션 롤백 전파)도 예방 - 테스트 코드: LocalDateTime.now(ZoneId) 정적 목 추가 Closes #246, #247 Co-Authored-By: Claude Sonnet 4.6 --- .../scheduler/RequestSchedulerService.java | 3 +- .../UserLifecycleTransactionalService.java | 94 +++++++++++++++++++ .../scheduler/UserSchedulerService.java | 86 ++--------------- .../RequestSchedulerServiceTest.java | 2 + 4 files changed, 106 insertions(+), 79 deletions(-) create mode 100644 src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/UserLifecycleTransactionalService.java diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerService.java index 07838c4..2bf469d 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerService.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerService.java @@ -14,6 +14,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; /** @@ -33,7 +34,7 @@ public class RequestSchedulerService { @Scheduled(cron = "0 00 08 * * ?", zone = "Asia/Seoul") public void runScheduler() { log.info("🗓️ [스케줄러 시작] 만료 계정 관리 작업"); - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul")); sendPreExpiryNotification(now.plusDays(7), "7일"); sendPreExpiryNotification(now.plusDays(3), "3일"); diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/UserLifecycleTransactionalService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/UserLifecycleTransactionalService.java new file mode 100644 index 0000000..7572114 --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/UserLifecycleTransactionalService.java @@ -0,0 +1,94 @@ +package DGU_AI_LAB.admin_be.domain.scheduler; + +import DGU_AI_LAB.admin_be.domain.alarm.service.AlarmService; +import DGU_AI_LAB.admin_be.domain.users.entity.User; +import DGU_AI_LAB.admin_be.domain.users.repository.UserRepository; +import DGU_AI_LAB.admin_be.global.util.MessageUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; + +/** + * 유저 생명주기 처리를 트랜잭션 경계 안에서 실행합니다. + * UserSchedulerService의 self-call 한계를 우회하고, 유저별 독립 트랜잭션을 보장합니다. + * (H-7: LazyInitializationException 방지, H-11: 단일 트랜잭션으로 인한 롤백 전파 방지) + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class UserLifecycleTransactionalService { + + private final UserRepository userRepository; + private final AlarmService alarmService; + private final MessageUtils messageUtils; + + private static final int INACTIVE_MONTHS = 3; + + /** + * 특정 유저의 비활성 여부를 판단하고, 경고 알림 발송 또는 Soft Delete를 수행합니다. + * 유저별 독립 트랜잭션으로 실행되어, 한 유저 실패가 다른 유저 처리에 영향을 주지 않습니다. + */ + @Transactional + public void processInactiveUser(Long userId, LocalDateTime now) { + User user = userRepository.findById(userId).orElseThrow(); + + LocalDateTime lastActivity = user.getLastLoginAt(); + + // Lazy 컬렉션을 트랜잭션 내에서 접근 (H-7 fix) + if (!user.getRequests().isEmpty()) { + LocalDateTime lastPodExpire = user.getRequests().stream() + .map(req -> req.getExpiresAt()) + .max(LocalDateTime::compareTo) + .orElse(LocalDateTime.MIN); + + if (lastPodExpire.isAfter(lastActivity)) { + lastActivity = lastPodExpire; + } + } + + LocalDateTime deleteDate = lastActivity.plusMonths(INACTIVE_MONTHS); + long daysLeft = ChronoUnit.DAYS.between(now.toLocalDate(), deleteDate.toLocalDate()); + + if (daysLeft == 7 || daysLeft == 3 || daysLeft == 1) { + sendWarningAlert(user, daysLeft, deleteDate); + } else if (daysLeft <= 0) { + softDeleteUser(user); + } + } + + /** + * 특정 유저를 DB에서 완전 삭제(Hard Delete)합니다. + * 독립 트랜잭션으로 실행되어, 실패 시 다른 유저의 처리에 영향을 주지 않습니다. + */ + @Transactional + public void hardDeleteUser(Long userId, String email) { + userRepository.findById(userId).ifPresent(user -> { + userRepository.delete(user); + log.info("계정 영구 삭제(Hard Delete) 완료: ID={}, Email={}", userId, email); + }); + } + + private void sendWarningAlert(User user, long daysLeft, LocalDateTime deleteDate) { + String dateStr = deleteDate.toLocalDate().toString(); + String subject = messageUtils.get("notification.user.delete-warning.subject", String.valueOf(daysLeft)); + String body = messageUtils.get("notification.user.delete-warning.body", + user.getName(), String.valueOf(daysLeft), dateStr); + + alarmService.sendAllAlerts(user.getName(), user.getEmail(), subject, body); + log.info("경고 알림 발송: {} ({}일 전)", user.getEmail(), daysLeft); + } + + private void softDeleteUser(User user) { + user.withdraw(); + + String subject = messageUtils.get("notification.user.soft-delete.subject"); + String body = messageUtils.get("notification.user.soft-delete.body", user.getName()); + + alarmService.sendAllAlerts(user.getName(), user.getEmail(), subject, body); + log.info("계정 비활성화(Soft Delete) 완료: {}", user.getEmail()); + } +} diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/UserSchedulerService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/UserSchedulerService.java index 93f5be1..7bf03cf 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/UserSchedulerService.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/UserSchedulerService.java @@ -1,18 +1,14 @@ package DGU_AI_LAB.admin_be.domain.scheduler; -import DGU_AI_LAB.admin_be.domain.alarm.service.AlarmService; import DGU_AI_LAB.admin_be.domain.users.entity.User; import DGU_AI_LAB.admin_be.domain.users.repository.UserRepository; -import DGU_AI_LAB.admin_be.global.util.MessageUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; +import java.time.ZoneId; import java.util.List; @Service @@ -21,114 +17,48 @@ public class UserSchedulerService { private final UserRepository userRepository; - private final AlarmService alarmService; - private final MessageUtils messageUtils; + private final UserLifecycleTransactionalService userLifecycleService; private static final int INACTIVE_MONTHS = 3; private static final int HARD_DELETE_YEARS = 1; // 매일 오전 09:00 실행 @Scheduled(cron = "0 0 9 * * ?", zone = "Asia/Seoul") - @Transactional public void runUserLifecycleScheduler() { log.info("👤 [스케줄러 시작] 사용자 계정 수명주기 관리"); - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul")); - // 1. Soft Delete 대상자 처리 (및 예고 알림) processInactiveUsers(now); - - // 2. Hard Delete 대상자 처리 processHardDeleteUsers(now); log.info("👤 [스케줄러 종료]"); } - /** - * 장기 미접속자 조회 및 처리 (경고 알림 OR Soft Delete) - */ private void processInactiveUsers(LocalDateTime now) { - // 기준일: (오늘 + 8일) - 3개월 (7일 전 알림을 위해 여유 있게 조회 후 로직에서 필터링) - // plusDays 후 minusMonths 순서여야 달력 연산 오차 없이 D-7 대상자를 정확히 포함함 LocalDateTime searchThreshold = now.plusDays(8).minusMonths(INACTIVE_MONTHS); List inactiveCandidates = userRepository.findInactiveUsers(searchThreshold); for (User user : inactiveCandidates) { try { - // 이 유저의 "활동 만료일(삭제 예정일)" 계산 - // 만료일 = Max(LastLogin, LastPodExpire) + 3개월 - LocalDateTime lastActivity = user.getLastLoginAt(); - - // 쿼리에서 이미 필터링했지만, Java 단에서 정확한 D-Day 계산을 위해 다시 확인 - // Request는 Lazy Loading이므로 트랜잭션 내에서 접근 가능 - if (!user.getRequests().isEmpty()) { - LocalDateTime lastPodExpire = user.getRequests().stream() - .map(req -> req.getExpiresAt()) - .max(LocalDateTime::compareTo) - .orElse(LocalDateTime.MIN); - - if (lastPodExpire.isAfter(lastActivity)) { - lastActivity = lastPodExpire; - } - } - - LocalDateTime deleteDate = lastActivity.plusMonths(INACTIVE_MONTHS); - long daysLeft = ChronoUnit.DAYS.between(now.toLocalDate(), deleteDate.toLocalDate()); - - // 1) 예고 알림 (D-7, D-3, D-1) - if (daysLeft == 7 || daysLeft == 3 || daysLeft == 1) { - sendWarningAlert(user, daysLeft, deleteDate); - } - // 2) Soft Delete 실행 (D-Day 또는 그 이후) - else if (daysLeft <= 0) { - softDeleteUser(user); - } - + // 유저별 독립 트랜잭션으로 처리 — H-7(LazyInit), H-11(롤백 전파) 방지 + userLifecycleService.processInactiveUser(user.getUserId(), now); } catch (Exception e) { log.error("유저({}) 수명주기 처리 중 오류: {}", user.getEmail(), e.getMessage()); } } } - private void sendWarningAlert(User user, long daysLeft, LocalDateTime deleteDate) { - String dateStr = deleteDate.toLocalDate().toString(); - - String subject = messageUtils.get("notification.user.delete-warning.subject", String.valueOf(daysLeft)); - String body = messageUtils.get("notification.user.delete-warning.body", - user.getName(), String.valueOf(daysLeft), dateStr); - - alarmService.sendAllAlerts(user.getName(), user.getEmail(), subject, body); - log.info("경고 알림 발송: {} ({}일 전)", user.getEmail(), daysLeft); - } - - private void softDeleteUser(User user) { - user.withdraw(); // isActive = false, deletedAt = now - - String subject = messageUtils.get("notification.user.soft-delete.subject"); - String body = messageUtils.get("notification.user.soft-delete.body", user.getName()); - - alarmService.sendAllAlerts(user.getName(), user.getEmail(), subject, body); - log.info("계정 비활성화(Soft Delete) 완료: {}", user.getEmail()); - } - - /** - * Hard Delete (개인정보 완전 삭제) - */ private void processHardDeleteUsers(LocalDateTime now) { LocalDateTime hardDeleteThreshold = now.minusYears(HARD_DELETE_YEARS); List hardDeleteTargets = userRepository.findUsersForHardDelete(hardDeleteThreshold); for (User user : hardDeleteTargets) { try { - Long userId = user.getUserId(); - String email = user.getEmail(); - - // DB 완전 삭제 (Cascade 설정으로 인해 연관 Request도 삭제됨) - userRepository.delete(user); - - log.info("계정 영구 삭제(Hard Delete) 완료: ID={}, Email={}", userId, email); + // 유저별 독립 트랜잭션으로 처리 — processInactiveUsers 결과에 영향 없음 + userLifecycleService.hardDeleteUser(user.getUserId(), user.getEmail()); } catch (Exception e) { log.error("Hard Delete 실패: {}", user.getEmail(), e); } } } -} \ No newline at end of file +} diff --git a/src/test/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerServiceTest.java b/src/test/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerServiceTest.java index 176fd35..60c2aad 100644 --- a/src/test/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerServiceTest.java +++ b/src/test/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerServiceTest.java @@ -25,6 +25,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -115,6 +116,7 @@ void runScheduler_IntegrationTest() { // --- Given: 시간 고정 & 스케줄러 실행 --- try (MockedStatic mockedTime = Mockito.mockStatic(LocalDateTime.class, Mockito.CALLS_REAL_METHODS)) { mockedTime.when(LocalDateTime::now).thenReturn(MOCK_NOW); + mockedTime.when(() -> LocalDateTime.now(any(ZoneId.class))).thenReturn(MOCK_NOW); requestSchedulerService.runScheduler(); }