팀 프로젝트를 진행하면서 꽤 머리 아픈 로직을 담당하게 되었다.
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를 조회한다.
조회 조건
- 도전 중(CHALLENGE) 중인 Challenge
- 익일 인증 게시글을 업로드하지 않았거나 인증 게시글이 존재하지 않는 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를 너무 헤비하게 코드를 짠 것 같다. 단일책임의 원칙에 대해 조금 더 공부해 본 후 리팩토링이 필요할 것 같다. 또 테스트 코드도 성공은 했지만 베스트로 작성한 코드라고 생각은 들지 않는다. 유닛 테스트는 가볍고 빨라야 한다고 배웠는데 지금 내가 짠 코드는 헤비하고 가독성도 떨어진다.
그래도 고려해야 될 부분이 많았던 로직이어서 걱정이 많았는데 로직이 잘 작동되는 걸 보니 굉장히 뿌듯했다!
'Spring' 카테고리의 다른 글
Spring - Spring으로 AWS S3에 이미지 업로드하기2: Spring에서 기능 구현 (0) | 2023.03.06 |
---|---|
Spring Security - OAuth2와 JWT로 로그인 구현하기(Kakao, Google, Naver) (6) | 2023.03.05 |
Spring - No Offset 페이지네이션으로 페이징 성능을 개선해보자! (0) | 2023.02.28 |
Spring - 의존관계 주입(DI) 4가지 방법 (0) | 2022.11.30 |
Spring - 옵션 처리 3가지 방법 (0) | 2022.11.30 |