하드코딩하지 않고 우아하게 스케줄링 이용하기
현재까지의 미미키는 기술적 챌린징으로 가져갈만했던 부분이 크게 있진 않았다. 하지만 진행하면서 새롭게 알게된 CronExpression에 대해서 소개해보려고 한다.
# CronExpression을 사용하게 된 계기
우리 서비스는 인기 급상승 밈과 최근 많이 공유된 밈을 제공하고 있었다. 인급밈의 경우 각종 지표를 집계해야하기에 때에 따라 쿼리 실행시간이 오래걸릴 수 있었기 때문에 스케줄링 기반 캐싱을 도입하기로 했다.
그리고 밈 공유는 기획적으로 일정 시간마다 갱신해주기로 했기 때문에 이를 해결하기 위해도 스케줄링이 필요했다.
# 크론식을 하드코딩하면 되는거 아니야?
```java
@Slf4j
@Service
public class MemeAggregationLookUpCacheProxyService implements MemeAggregationLookUpService {
private final MemeAggregationLookUpService memeAggregationLookUpService;
private volatile List<MemeSimpleResponse> mostPopularMemesCache;
@Override
public List<MemeSimpleResponse> getMostPopularMemes() {
if (mostPopularMemesCache == null) {
mostPopularMemesCache = memeAggregationLookUpService.getMostPopularMemes();
}
return mostPopularMemesCache;
}
@PostConstruct
public void warmUpCache() {
mostPopularMemesCache = memeAggregationLookUpService.getMostPopularMemes();
}
@Scheduled(cron = "0 */30 * * * *")
public void refreshCache() {
log.info("Refreshing most popular memes cache");
mostPopularMemesCache = memeAggregationLookUpService.getMostPopularMemes();
log.info("Cache refreshed successfully");
}
}
```
시작은 간단하게 접근했다. 우리가 흔히 알고 있는 방식인 `@Scheduled` 어노테이션과 크론식을 하드코딩하는 형태로 도입했다. 사실 여기까지는 아무런 문제가 없었다. 왜냐하면 단순히 인급밈 같은 경우는 단순히 크론식에 따라서 스케줄링만 돌면 되었기 때문이었다. 그렇다면 어디서 문제가 발생했을까 ?
# 다음 갱신 시간이 언제인지 알고싶어요!
단순히 하드코딩해서 특정 시간에 스케줄링만 돈다면 문제가 없었다. 하지만 새로운 요구사항이 생겼다. 위의 밈열차(공유가 많은 밈)은 다음 갱신주기가 언제인지 응답에 필요했다. 로직이 어떻게 될 지 잠깐 보고가자
```java
@Scheduled(cron = "0 0 4 * * *")
public void scheduledRefresh() {
refreshCache(timeProvider);
log.info("Scheduled refresh of shared meme cache");
}
private void refreshCache(TimeProvider timeProvider) {
try {
List<MemeSimpleResponse> memes = memeAggregationLookUpService.getMostFrequentSharedMemes();
LocalDateTime now = timeProvider.now();
LocalDateTime nextUpdate = now.withHour(4).withMinute(0).withSecond(0);
if (!now.isBefore(nextUpdate)) {
nextUpdate = nextUpdate.plusDays(1);
}
cachedData = new MostSharedMemes(memes, nextUpdate);
log.info("Shared meme cache refreshed. Next update at: {}", nextUpdate);
} catch (Exception e) {
log.error("Failed to refresh shared meme cache", e);
}
}
```
다음 갱신주기를 확인하기 위해서 refreshCache 로직에서 cron 식의 주기에 맞춰 다음 갱신주기를 확인하고 있다.
물론 음 뭐 그럴수도 있지 라고 생각할 수 있다. 그리고 이게 어려운일인가? 라고 생각할 수 도 있다.
하지만 나는 관리포인트가 두개라는게 마음에 들지 않았다. 만약 갱신주기가 30분으로 바뀐다면 항상 개발자는 의식적으로 크론식을 바꾸고, refreshCache의 다음 갱신시간로직을 새롭게 작성해야했다.
# Spring 의 CronExpression
스프링에는 다행스럽게도 CronExpression 이라는 클래스가 있었고, 이를 발견해서 내가 원하는 기능을 할 수 있는지 확인해보았다.
우선 어떻게 해당 클래스를 사용할 수 있는지 살펴보았다. 우선 new 를 통한 생성자는 private로 닫혀있었고, 정적 팩토리메서드 parse 를 이용하도록 되어있었다.
사실 코드 내용을 보는 것 보다 예시를 보는게 더 쉬울 수 있다.
예시로 나오는 항목들을 보니, 우리가 흔히 사용하던 크론식을 String으로 넣으면 되는 것으로 보인다. 그리고 @yearly, @daily와 같은 형식도 지원한다고 되어있다.
생성자를 살펴봤으니내가 필요한 기능인 다음 갱신주기를 확인 할 수 있는지 확인해봐야했다.
```java
@Test
void CronExpression_next_메서드를_확인한다() {
CronExpression expression = CronExpression.parse("0 0 4 * * *");
LocalDateTime next = expression.next(LocalDateTime.of(2025, 8, 14, 3, 59, 59));
LocalDateTime tomorrow = expression.next(LocalDateTime.of(2025, 8, 14, 4, 0, 1));
Assertions.assertThat(next).isEqualTo(LocalDateTime.of(2025, 8, 14, 4, 0, 0));
Assertions.assertThat(tomorrow).isEqualTo(LocalDateTime.of(2025, 8, 15, 4, 0, 0));
}
```
CronExpression에는 next 라는 메서드가 존재한다. 해당 메서드를 사용하면 다음 갱신주기가 언제인지 알 수 있었다. 실제 테스트를 작성하면서 내가 원하는대로 동작하는지 확인해보았다. 아주 나의 상황에 적합했다. 테스트는 예상했던 대로 초록불이 떴고, CronExpression 클래스를 활용해 관리포인트를 줄여보자
# CronExpression 활용
이제 CronExpression을 통해서 관리포인트를 줄일 수 있을 것 같고, 내가 원하는 기능들이 지원되는 것을 확인했으니 실제 내 코드에 적용해보자
```java
@Slf4j
@Service
public class SharedMemeScheduleCacheService {
private static final String SHARED_MEME_RENEWAL_CRON = "0 0 4 * * *";
private final CronExpression cronExpression = CronExpression.parse(SHARED_MEME_RENEWAL_CRON);
private final MemeAggregationLookUpService memeAggregationLookUpService;
private final TimeProvider timeProvider;
private volatile MostSharedMemes cachedData;
public SharedMemeScheduleCacheService(MemeAggregationLookUpServiceImpl memeAggregationLookUpService, TimeProvider timeProvider) {
this.memeAggregationLookUpService = memeAggregationLookUpService;
this.timeProvider = timeProvider;
}
public MostSharedMemes getMostSharedMemes() {
if (cachedData == null) {
refreshCache(timeProvider);
}
return cachedData;
}
@PostConstruct
public void initializeCache() {
log.info("Initializing shared meme cache");
refreshCache(timeProvider);
}
@Scheduled(cron = SHARED_MEME_RENEWAL_CRON)
public void scheduledRefresh() {
log.info("Scheduled refresh of shared meme cache");
refreshCache(timeProvider);
}
private void refreshCache(TimeProvider timeProvider) {
try {
List<MemeSimpleResponse> memes = memeAggregationLookUpService.getMostFrequentSharedMemes();
LocalDateTime nextUpdateTime = cronExpression.next(timeProvider.now());
cachedData = new MostSharedMemes(memes, nextUpdateTime);
log.info("Shared meme cache refreshed. Next update at: {}", nextUpdateTime);
} catch (Exception e) {
log.error("Failed to refresh shared meme cache", e);
}
}
}
```
주요 변경사항은 아래와 같다.
1. 크론식을 상수로 관리한다.
2. CronExpression의 next를 통해, 조건문을 직접 작성하지 않는다.
이를 통해서 우리는 관리포인트를 상수 하나로 통합 할 수 있었다. 사실은 CronExpression에서 생성자를 제외한 공개된 API가 next와 static한 메서드인 isValidExpression 두가지밖에 없기 때문에 파악하는것도 어렵지 않았다. 생각해보면 그렇게 기능이 많을 이유도 없는 유틸리티 클래스이긴하다.
# 실제 사용해보니 어떨까?
CronExpression을 도입하고 나니 예상했던 것보다 더 많은 장점들을 발견할 수 있었다.
1. 유지보수성 대폭 개선
이제 스케줄 주기를 변경하고 싶다면 상수 SHARED_MEME_RENEWAL_CRON 하나만 수정하면 된다. 별도의 시간 계산 로직을 건드릴 필요가 없다.
```java
// 30분마다로 변경하고 싶다면?
private static final String SHARED_MEME_RENEWAL_CRON = "0 */30 * * * *";
// 끝! 다른 건 건드릴 필요 없음
2. 복잡한 스케줄도 쉽게
// 평일 오전 9시, 오후 6시에만 실행
"0 0 9,18 * * MON-FRI"
// 매월 마지막 날 자정
"0 0 0 L * *"
// 매주 일요일 새벽 3시
"0 0 3 * * SUN"
```
이런 복잡한 패턴의 다음 실행 시간도 cronExpression.next()로 정확히 계산된다. 직접 구현했다면 상당히 복잡했을 로직들이다.
# 마치며
사실 CronExpression은 그렇게 복잡한 기술은 아니다. Spring에서 제공하는 간단한 유틸리티 클래스일 뿐이다. 하지만 이런 작은 발견들이 코드의 품질을 한 단계 끌어올려 주는 것 같다.
특히 단일 책임 원칙을 지키면서도 DRY 원칙도 함께 달성할 수 있다는 점이 마음에 든다. 크론 표현식 하나로 스케줄링도 하고, 다음 실행 시간 계산도 하니 말이다.
만약 여러분도 @Scheduled를 사용하면서 다음 실행 시간을 별도로 계산하고 있다면, CronExpression을 한번 고려해보길 권한다. 생각보다 코드가 깔끔해질 것이다.
그리고 무엇보다, "이런 것도 있구나" 하고 새로운걸 알게 되는 재미가 있다. 비록 기술적으로 대단한 챌린지는 아니었지만, 이런 소소한 개선들이 모여서 더 나은 서비스를 만들어가는 것 아닐까?
미미키 프로젝트는 아직 진행 중이다. 앞으로 더 흥미로운 기술적 챌린지들을 만나게 될 것 같은데, 그때는 또 어떤 새로운 발견들이 있을지 기대가 된다.