// Quartz Job 예시
@Component
public class ReservationJob implements Job {
public void execute(JobExecutionContext context) {
// 복잡한 설정 필요
}
}
장점: 클러스터링 지원, 동적 스케줄 변경
단점: 설정 복잡, 오버엔지니어링, 외부 의존성
// 배치 처리 + 실시간 처리 조합
@Scheduled(cron = "0 0 0 * * *") // 백그라운드 일괄 처리
+
goal.checkAndUpdateExpiredStatus(); // 조회 시 실시간 체크
장점: 성능 + 정확성 균형
단점: 구현 복잡도 약간 증가
배치 처리로 효율성을 확보하고, 실시간 체크로 정확성을 보장하는 이중 안전장치인 Spring Scheduler로 결정.
┌─────────────────────────────────────────────────────────────┐
│ 백그라운드 배치 처리 (Spring Scheduler) │
│ • 매일 자정: 만료된 목표 일괄 처리 │
│ • 매시간: 예약 상태 자동 변경 │
│ • 매일 오전: 만료 알림 발송 │
└─────────────────────────────────────────────────────────────┘
+
┌─────────────────────────────────────────────────────────────┐
│ 실시간 처리 (사용자 액션 시) │
│ • 목표 조회 시: 만료 상태 즉시 체크 │
│ • 예약 조회 시: 현재 상태 실시간 반영 │
└─────────────────────────────────────────────────────────────┘
// 도메인별 책임 분리로 유지보수성 향상
ReservationScheduler → 예약 관련 자동화
MembershipScheduler → 멤버십 관련 자동화
FitnessGoalScheduler → 목표 관련 자동화
의사결정 이유 : 단일 책임 원칙, 코드 가독성, 확장 용이성
// 시스템 부하 분산을 위한 시간대별 분리
00:00 (자정) → 목표 만료 처리 (하루 단위 정리)
01:00 (새벽) → 장기 대기 예약 취소 (정리 작업)
06:00 (새벽) → 멤버십 자동 활성화 (사용 전 준비)
09:00 (오전) → 만료 알림 발송 (사용자 활동 시간)
매시간 00분 → 예약 상태 변경 + 2시간 전 알림
의사결정 이유 :
// 실시간 + 배치 처리 조합
@Entity
public class FitnessGoal {
// 실시간 체크 (조회 시마다)
public void checkAndUpdateExpiredStatus() {
if (goalStatus == GoalStatus.ACTIVE && LocalDate.now().isAfter(endDate)) {
this.goalStatus = GoalStatus.EXPIRED;
}
}
}
@Scheduled(cron = "0 0 0 * * *") // 배치 처리 (백그라운드)
public void updateExpiredGoals() {
List<FitnessGoal> activeGoals = repository.findByGoalStatus(ACTIVE);
activeGoals.forEach(FitnessGoal::checkAndUpdateExpiredStatus);
}
의사결정 이유:
// 단계적 알림으로 사용자 이탈 방지
멤버십 만료: 3일 전 → 1일 전 → 당일
예약 알림: 하루 전 → 2시간 전
목표 처리: 자정 일괄 (사용자 수면 시간)
의사결정 이유 : 사용자 준비 시간 확보, 이탈률 감소
// 읽기 전용 최적화
@Transactional(readOnly = true)
public void sendThreeDaysBeforeExpirationNotice() { ... }
// 쓰기 작업 일관성 보장
@Transactional
public void completeExpiredReservations() { ... }
// 안전한 예외 처리
try {
fitnessGoalService.updateExpiredGoals();
log.info("처리 완료");
} catch (Exception e) {
log.error("처리 중 오류 발생", e); // 서비스 중단 없이 로깅
}
예시)
// Before: 수동 관리
- 관리자가 직접 상태 변경
- 사용자 문의 증가
- 데이터 불일치 가능성
// After: 자동화
- 무인 상태 관리
- 사용자 만족도 향상
- 데이터 일관성 보장
// 각 스케줄러는 독립적으로 동작
ReservationScheduler 실패 → MembershipScheduler 정상 동작
예약 스케줄러가 무너져도 다른 스케줄러는 살아있기에 독립적으로 동작한다.
// 같은 작업을 여러 번 실행해도 결과 동일
if (goalStatus == GoalStatus.ACTIVE && LocalDate.now().isAfter(endDate)) {
this.goalStatus = GoalStatus.EXPIRED; // 이미 EXPIRED면 변경 없음
}
log.info("만료된 예약 {}개를 COMPLETED로 변경합니다.", expiredReservations.size());
log.info("사용자 ID: {}, 멤버십: {} 자동 활성화 완료", userId, membershipName);