Spring

Spring - Scheduler로 매일 자정 실행되는 로직을 짜보자

Cold Bean 2023. 3. 2. 23:02
728x90

팀 프로젝트를 진행하면서 꽤 머리 아픈 로직을 담당하게 되었다.

Spring Scheduler를 사용해서 매일 자정 실행되는 로직을 짜는 것이다. 앞서 프로젝트를 간단히 소개할 필요가 있겠다.

'66Challenge'는 66일 동안 올바른 습관을 만들 수 있도록 도와주는 목표 달성 서비스이다.

 

66Challenge는 특정 습관을 시작하면 66일 동안 쉬지 않고 인증해야 한다. 단, 두 개의 와일드카드가 주어지기 때문에 2일 정도는 참여하지 않아도 문제가 없다. 와일드카드를 모두 소진한 후 하루라도 인증을 하지 않으면 자동으로 습관 실패 처리가 된다.  <- 바로 이 부분을 내가 담당하게 되었다.

 

ERD

아래 내용을 참고하기 위해 ERD를 첨부했다. 아래 테이블 중 3개의 테이블을 이번 로직에서 다루게 된다.

  • Habit : 습관 등록을 통한 습관 챌린지 정보 관리
  • Challenge : 회원(Users)의 습관(Habit) 챌린지 참여 정보 관리
  • Wildcard : 챌린지(Challenge)에서 사용한 와일드 카드 관리.
  • Auth : 회원(Users)의 습관 챌린지 참여 인증 게시물 관리

요구사항

  • 매일 자정 주기적으로 검증이 이루어져야 합니다.
  • 전날 인증글을 게시하지 않은 진행중인 모든 Challenge를 조회해야 합니다.
  • 조회된 각 Challenge의 조건에 맞게 상태를 변경한다. 실행한다.
    • 익일 인증글을 게시하지 못하면 Wildcard를 1개 사용하여 인증을 대체한다. (최대 2개 사용 가능)
    • Wildcard를 이미 모두 사용했다면 해당 Challenge는 실패(Fail) 처리된다.
    • Wildcard로 마지막 66일을 채워도 Challenge는 성공(Success)한다.

요구사항 01. 매일 자정 주기적으로 검증이 이루어져야 한다

매일 자정 주기적으로 작업을 실행해야 하기 때문에 @Scheduled를 사용했다. application.yml에 cron 주기를 설정해 주었다.

@EnableScheduling	// 추가
@SpringBootApplication
public class ServerApplication {

   	public static void main(String[] args) {
		SpringApplication.run(ServerApplication.class, args);
	}
}
...
cron:
  cron1: 0 0 0 * * *   # 매일 자정 실행
...
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ChallengeService {

     private static final DateTimeFormatter formatter
            = DateTimeFormatter.ofPattern("mm:ss:SSS");

		...

    @Transactional
    @Scheduled(cron = "${cron.cron1}")
    public List<Challenge> notAuthTodayCheck() {
	log.info("로직 실행시간 - {}", formatter.format(LocalDateTime.now()));
        ...
    }

		...
}

문제 발생!!

😱 이 때는 몰랐지만 EC2 배포 후 지정한 Schedule에 로직이 실행되지 않는 문제가 발생했었다.
관련 내용은 따로 정리해 두었다. 링크 참고

요구사항 02. 익일 인증글을 게시하지 않은 진행 중인 모든 Challenge를 조회한다.

조회 조건

  1. 도전 중(CHALLENGE) 중인 Challenge
  2. 익일 인증 게시글을 업로드하지 않았거나 인증 게시글이 존재하지 않는 Challenge

Challenge 엔티티에 마지막 인증 게시일을 관리하는 lastAuthAt 필드를 생성해서 lastAuthAt을 기준으로 익일 인증하지 않은 게시물들을 조회했다.

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuthService {
    private final AuthRepository authRepository;
    private final ChallengeService challengeService;
    private final CustomBeanUtils<Auth> beanUtils;
    private final FileUploadService fileUploadService;

    @Transactional
    public Auth createAuth(Auth auth, Long challengeId) {
        Challenge challenge = challengeService.findChallenge(challengeId);
        auth.setChallenge(challenge);
        challenge.updateAuthAt(LocalDateTime.now());   // 인증글 게시일 갱신해주는 로직 추가

        // 챌린지 성공 여부 확인
        if (challenge.successCheck()) {
            challenge.changeStatus(SUCCESS);
        }

        return authRepository.save(auth);
    }
		...
}

 

@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Challenge extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long challengeId;

    private LocalDateTime lastAuthAt;	// 마지막 인증 게시일 추가

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "USER_ID")
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "HABIT_ID")
    private Habit habit;

    @Enumerated(EnumType.STRING)
    private Status status;

    @OneToMany(mappedBy = "challenge", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Wildcard> wildcards = new ArrayList<>();

    @OneToMany(mappedBy = "challenge", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Auth> auths = new ArrayList<>();

    public void changeStatus(Challenge.Status status) {		// 참여중인 습관 챌린지의 상태 변경 로직
        if (status.equals(Status.CHALLENGE)) {
            LocalDateTime localDateTime = LocalDateTime.now().withNano(0);
            this.setCreatedAt(localDateTime);
            this.setLastModifiedAt(localDateTime);
            this.lastAuthAt = null;
        }
        this.status = status;
    }

    public void updateAuthAt(LocalDateTime localDateTime) {	   // 마지막 인증 게시일 갱신 로직
        this.lastAuthAt = localDateTime;
    }

    public Boolean successCheck() {		//  66일 습관 챌린지를 성공했는지 확인하는 로직
        return this.getCreatedAt().toLocalDate().plusDays(66).equals(this.lastAuthAt.toLocalDate());
    }

    ...

    public enum Status {
        CHALLENGE(1),
        SUCCESS(2),
        FAIL(3);

        @Getter
        private final int type;

        Status(int type) {
            this.type = type;
        }
    }
}
@Repository
@RequiredArgsConstructor
public class ChallengeCustomRepositoryImpl implements ChallengeCustomRepository {

    private final JPAQueryFactory jpaQueryFactory;

    ...
		
	/**
	* 조건1) 도전중(CHALLENGE)중인 Challenge
	* 조건2) 익일 인증 게시글을 업로드하지 않았거나 인증 게시글이 존재하지 않는 Challenge
	**/
    @Override
    public List<Challenge> findAllByNotAuthToday(Challenge.Status status, LocalDateTime startDatetime, LocalDateTime endDatetime) {

        return jpaQueryFactory
                .selectFrom(challenge)
                .where(
                        challenge.status.eq(CHALLENGE),    // 도전(CHALLENGE)중인 습관 챌린지  
                        challenge.lastAuthAt.notBetween(startDatetime, endDatetime)    // 정해진 기간 내에 인증글이 등록되지 않은 챌린지
                        .or(challenge.lastAuthAt.isNull()))     // 또는 인증게시글이 존재하지 않는 챌린지 조회
                .fetch();
		}
}

요구사항 03. 조회된 각 Challenge의 조건에 맞게 상태를 변경한다.

상태 변경 조건

  • 익일 인증글을 게시하지 못하면 Wildcard를 1개 사용하여 인증을 대체한다. (최대 2개 사용 가능, 상태 변경 없음)
  • Wildcard를 이미 모두 사용했다면 해당 Challenge는 실패(Fail) 처리된다.
  • Wildcard로 마지막 66일을 채워도 Challenge는 성공(Success)한다.
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ChallengeService {

		private static final DateTimeFormatter formatter
            = DateTimeFormatter.ofPattern("mm:ss:SSS");

		...

    @Transactional
    @Scheduled(cron = "${cron.cron1}")
    public List<Challenge> notAuthTodayCheck() {
        log.info("로직 실행시간 - {}", formatter.format(LocalDateTime.now()));   // 자정에 실행되었는지 확인하기 위한 로그 
        LocalDateTime startDatetime = LocalDateTime.of(LocalDate.now().minusDays(1), LocalTime.of(0, 0, 0));     // 익일 00:00:00
        LocalDateTime endDatetime = LocalDateTime.of(LocalDate.now().minusDays(1), LocalTime.of(23, 59, 59));    // 익일 23:59:59
				
		// 도전(CHALLENGE)중인 Challenge 중 익일 인증하지 않은 모든 Challenge 조회
        List<Challenge> challenges = challengeRepository.
                findAllByNotAuthToday(CHALLENGE, startDatetime, endDatetime); 

		/**
		* 각 Challenge마다 보유한 Wildcard 조회(Challenge마다 Wildcard를 최대 2개 사용 가능)
		* 사용 가능한 Wildcard가 있다면 Wildcard 사용(Wildcard Row 생성)
		* 사용 가능한 Wildcard가 없다면 Challenge 실패(Fail) 처리
		* 마지막 66일째를 Wildcard로 채울 경우도 Challenge 성공(Success) 처리
		**/
        challenges.forEach(challenge ->
        {
            int wildcardCount = challenge.getWildcards() == null ? 0 : challenge.getWildcards().size();

            if (wildcardCount >= 2) {
                challenge.changeStatus(FAIL);
            } else {
                wildcardService.useWildcard(challenge);
                challenge.updateAuthAt(LocalDateTime.now().minusDays(1));
                if (challenge.successCheck()) challenge.changeStatus(SUCCESS);
            }
        });
        return challenges;
    }
		...
}
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class WildcardService {

    private final WildcardRepository wildcardRepository;

    @Transactional
    public Wildcard useWildcard(Challenge challenge) {
        Wildcard wildcard = Wildcard.builder().challenge(challenge).build();

        return wildcardRepository.save(wildcard);
    }

    ...
}
@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Challenge extends BaseTimeEntity {
		...

    public Boolean successCheck() {
        return this.getCreatedAt().toLocalDate().plusDays(66).equals(this.lastPostedAt.toLocalDate());
    }

    ...
}
@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Wildcard extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long wildcardId;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "CHALLENGE_ID")
    private Challenge challenge;
}

테스트

로직이 잘 실행되는지 테스트해보자.

  • 익일 인증글을 게시하지 못하면 Wildcard를 1개 사용하여 인증을 대체한다. (최대 2개 사용 가능, 상태 변경 없음)
  • Wildcard를 이미 모두 사용한 경우 해당 Challenge는 실패(Fail) 처리된다.
  • Wildcard로 마지막 66일을 채워도 Challenge는 성공(Success)한다.
    @Test
    void notAuthTodayCheck() {
        // given
        LocalDateTime time = LocalDateTime.now();
        LocalDateTime successTime = LocalDateTime.now().minusDays(67);
        Wildcard wildcard = Wildcard.builder().build();

        Challenge fail = Challenge.builder().challengeId(1L).status(CHALLENGE).wildcards(List.of(wildcard, wildcard)).build();    // Wildcard를 모두 사용했기 때문에 인증하지 않았기 때문에 Fail
        fail.setCreatedAt(time);
        Challenge challenge = Challenge.builder().challengeId(2L).status(CHALLENGE).build();    // 사용할 수 있는 Wildcard가 존재하기 때문에 상태 변경 없음
        challenge.setCreatedAt(time);
        Challenge success = Challenge.builder().challengeId(3L).status(CHALLENGE).build();   // 습관 66일을 채원 챌린지 Success
        success.setCreatedAt(successTime);

        given(challengeRepository.findAllByNotAuthToday(Mockito.any(Challenge.Status.class), Mockito.any(LocalDateTime.class), Mockito.any(LocalDateTime.class)))
                .willReturn(List.of(fail, challenge, success));
        given(wildcardService.useWildcard(Mockito.any(Challenge.class))).willReturn(wildcard);

        // when
        List<Challenge> challenges = challengeService.notAuthTodayCheck();
        Challenge failChallenge = challenges.get(0);
        Challenge normalChallenge = challenges.get(1);
        Challenge successChallenge = challenges.get(2);

        // then
        assertEquals(FAIL, failChallenge.getStatus());
        assertEquals(CHALLENGE, normalChallenge.getStatus());
        assertEquals(SUCCESS, successChallenge.getStatus());
    }

테스트 성공!

마무리

아쉬운 점은 ChallengeService를 너무 헤비하게 코드를 짠 것 같다. 단일책임의 원칙에 대해 조금 더 공부해 본 후 리팩토링이 필요할 것 같다. 또 테스트 코드도 성공은 했지만 베스트로 작성한 코드라고 생각은 들지 않는다. 유닛 테스트는 가볍고 빨라야 한다고 배웠는데 지금 내가 짠 코드는 헤비하고 가독성도 떨어진다.

그래도 고려해야 될 부분이 많았던 로직이어서 걱정이 많았는데 로직이 잘 작동되는 걸 보니 굉장히 뿌듯했다!

728x90