JAVA/JAVA | Spring 학습기록

[Spring] Spring Boot + Shedlock 이용한 API 응답 캐싱 폴링

kth990303 2024. 6. 12. 18:53
반응형

우리 팀 API가 아닌 타 팀(또는 타사) API를 호출하여 응답을 이용하긴 하는데, 응답이 너무 반복적으로 같은 경우가 있다.

예를 들어 국가 별 수도 목록을 구하는 api를 매번 호출한다고 하자. 수도는 웬만해서는 변하지 않기 때문에, 이런 경우에는 응답을 캐싱해두면 좋아보인다.

 

하지만 가끔 캐싱만으로는 걱정되는 경우가 존재한다.

이번에 나는 캐싱 뿐 아니라, 폴링 로직을 추가하는 경험을 해보았는데 처음 경험해보는 것이어서 기록용으로 포스팅에 남겨두려고 한다.

 

포스팅 작성 시점 기준으로, 사내 개발환경에만 도입해보았고 실제 운영환경에 작성되지 않아 
틀린점이 존재할 수 있습니다. 틀린 점 및 보완할 점은 댓글로 남겨주세요!

폴링을 고려하게 된 계기

응답을 캐싱해두면, 그 다음번부터는 api를 호출할 필요 없이 캐시 데이터를 반환해주면 된다. 캐시 TTL이 지나지 않는 한은 말이다.

이말인 즉슨, 캐시 TTL 만큼은 해당 데이터가 갱신되지 않는다는 의미이다.

만약 캐시 TTL 기간 내에 데이터가 변하게 된다면? 해당 기간동안은 잘못된 데이터를 주고받게 된다.

이는 개인정보, 결제 관련된 도메인에서는 굉장히 위험한 결과를 반환할 수도 있다. (그렇게 되지 않도록 정책 및 밸리데이션을 잘 작성해야겠지만 말이다.)

 

따라서, 비교적 트래픽이 많아지는 것을 감안하더라도 주기적으로 폴링을 통해 API를 호출하여 refresh 하는 방안을 선택하게 됐다.

이 때, 폴링 주기는 캐시TTL 보다 짧게 가져가도록 하였다.

 

스프링에서는 매 시간마다 반복적인 작업을 할 수 있도록 Spring Scheduled 로 스케줄러 기능을 제공한다.

폴링 성격 상, 스프링 스케줄러로 구현을 하면 편할 듯하다.

하지만 문제가 하나 있다.

서버가 한 대인 경우가 아닌, 여러 대인 경우다.

특정 시각이 되면 scale out 된 여러 대의 서버들이 모두 해당 job을 수행하여 폴링을 시도할 것인데, 이렇게 되면 의도치 않은 결과가 나타날 수 있다. 멱등성이 보장되는 로직이라면 다행이겠지만, 결제가 포함되는 로직이라면? 굉장히 크리티컬할 것이다.

이를 막기 위해 ShedLock을 함께 도입하게 됐다.

 

https://github.com/lukas-krecan/ShedLock

 

GitHub - lukas-krecan/ShedLock: Distributed lock for your scheduled tasks

Distributed lock for your scheduled tasks. Contribute to lukas-krecan/ShedLock development by creating an account on GitHub.

github.com

 

ShedLock 을 걸면, 하나의 노드 (스레드 또는 서버) 에서 작업을 수행할 경우에 다른 노드에서는 패스하게 된다.

위 깃허브에 아래 문구가 존재한다.

 

Please note, that if one task is already being executed on one node, execution on other nodes does not wait, it is simply skipped.

 

번역: 한 노드에서 하나의 작업이 이미 실행 중인 경우 다른 노드에서의 실행은 기다리지 않고 단순히 건너뛰게 됩니다.

 

 

또한, ShedLock을 걸면 해당 폴링 로직을 수행하는 API 제공 서버가 다운된 경우에도 안전해질 수 있다.

API 제공 서버가 다운된 경우를 가정해보자.

우리는 해당 API를 폴링으로 호출하며, 호출할 때 timeout을 별도로 설정하지 않았다고 하자.

해당 서버가 다운된 경우, 우리는 api 응답을 하염없이 기다리기만 할 것이다. 만약 ShedLock이 없다면 다른 노드에서 또 폴링을 시도하거나, 아니면 폴링을 하기 위해 무한히 대기하려 할 것이다. 이 경우가 반복되면 결국 idle 노드가 존재하지 않아 우리 서버도 위험해질 수 있다.

 

하지만 ShedLock이 있다면 이미 폴링 job이 수행중인 상태이므로 다른 노드에서는 건너뛰고 다른 작업을 수행하게 될 것이다. 따라서 idle 노드가 부족해질 염려가 비교적 줄어드는 셈이다.


+)24.07.20 추가

shedlock 이 있더라도, lockAtMostFor 시간을 넘어서서 작업이 수행되는 경우에 문제가 된다.

lockAtMostFor 에 의해 락이 풀리게 되나 작업은 수행중이므로, 기존에 작업을 수행중인 스레드에 다시 더 락이 걸리거나 아니면 다른 스레드에 폴링로직이 수행돼 상대서버에 부하가 오히려 많이갈 수 있다.

공식 문서 상에도 이러한 경우, 의도보다 더 많은 프로세스가 잠금에 관여해 의도치않게 동작이 될 수 있다고 언급한다. 

(If the task takes longer than lockAtMostFor the resulting behavior may be unpredictable (more than one process will effectively hold the lock).)

 

따라서 이러한 경우까지 대비하려면 서킷브레이커 아키텍처를 추가하거나, 정책상으로 대비를 해주면 좋을 듯하다.

thanks to JIWOO.


 

 

이러한 안전상의 이유로 인해 ShedLock을 폴링 로직에 추가로 도입하게 됐다.


적용 코드

Build.gradle

implementation "net.javacrumbs.shedlock:shedlock-provider-redis-spring:4.44.0"
implementation "net.javacrumbs.shedlock:shedlock-spring:4.44.0"

(참고로 나는 Redis에 캐싱값을 저장할 예정이어서 shedlock-provider-redis-spring을 라이브러리로 등록해주었다. https://github.com/lukas-krecan/ShedLock 을 참고해서 jdbc, r2dbc 등 다른 라이브러리를 넣어줄 수 있다.)

 

Spring Boot 3 이라면 https://github.com/lukas-krecan/ShedLock 에 나와있는 최신 버전 (포스팅 작성 당시 5.13.0) 을 사용하면 된다.

 

스케줄러 Configuration 클래스 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Slf4j
@Configuration
@EnableAsync
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "60s")
public class SchedulingConfig implements SchedulingConfigurer {
 
    @Override
    public void configureTasks(ScheduledTaskRegistrar scheduledTaskRegistrar) {
        ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler();
        threadPoolTaskScheduler.setPoolSize(5);
        threadPoolTaskScheduler.setThreadNamePrefix("task-scheduler-");
        threadPoolTaskScheduler.initialize();
 
        scheduledTaskRegistrar.setTaskScheduler(threadPoolTaskScheduler);
    }
 
    @Bean
    public LockProvider lockProvider(RedisConnectionFactory connectionFactory) {
        return new RedisLockProvider(connectionFactory);
    }
}
 
cs

 

스케줄러 관련된 옵션을 설정해주는 클래스이다.

LockProvider은 shedlock 관련 코드이고, configureTasks는 스케줄러 스레드풀 관련 설정 오버라이딩 코드이다.

(참고로 나는 Redis에 캐싱값을 저장할 예정이어서 RedisLockProvider를 빈으로 등록해주었다. redis 외에도 Jdbc, R2DBC 등 여러 lock provider가 존재한다.)

 

@EnableScheduling, @EnableSchedulerLock 옵션을 SpringBootApplication 메인 클래스에 달아주어도 되지만,

메인 클래스와 설정config 옵션을 분리하는 것을 개인적으로 선호하여 Config파일을 만든 후 여기에 어노테이션들을 붙여주었다.

 

@EnableSchedulerLock의 defaultLockAtMostFor 옵션 설정은 필수이다. 최대 락 소유시간 디폴트값을 설정하라는 의미인데, 여기서 설정한대로 모든 락 시간이 결정되는 것이 아니다. 만약 특정 job의 lock 소유 최대시간을 별도로 조정하고 싶다면 @SchedulerLock의 lockAtMostFor로 설정하면, defaultLockAtMostFor 시간이 아닌 lockAtMostFor 시간만큼 설정된다.

 

@EnableAsync는 필수 사항은 아니지만, job 수행시간이 길어지거나 실패하는 경우 다른 스레드에 영향을 끼치지 않도록 비동기적으로 수행시키기 위해 설정해주었다. (다만, 뒤에 후술하겠지만 fixedRate 옵션으로 하기 위해선 @EnableAsync는 필수이다.) 또한, 이를 위해 threadPoolTaskScheduler.setPoolSize를 1이 아닌 적당한 값으로 설정해주었다.

 

 

참고) Scheduling 폴링 테스트를 위해 만들어준 것으로, ThreadPoolTaskScheduler를 빈으로 등록하는 방법이 아닌 ScheduledTaskRegistrar에 스케줄러를 등록하는 방법으로 진행하였다.

 

이렇게 할 경우, gracefulShutdown이 되지 않을 수 있다. 웬만해서는 ThreadPoolTaskScheduler를 빈으로 등록하여 gracefulShutdown 옵션들을 설정하길 추천한다. 해당 포스팅에선 gracefulShutdown이 중점적인 내용이 아니므로 넘어가도록 하겠다.

gracefulShutdown 내용 및 옵션들은 아래를 참고하자.

https://kth990303.tistory.com/464

 

[Spring] @async 로직 실패 일대기, ThreadPoolTaskExecutor의 awaitTerminate와 Async

애플리케이션을 만들다보면 비동기 로직을 호출할 때가 상당히 많다. 별도의 스레드에서 돌아가면서 추후에 응답값을 반환해도 되는 경우, 성능 향상을 위해 비동기를 쓰는 것. 하지만 그렇기

kth990303.tistory.com


job을 작성할 스케줄러 클래스 코드


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Slf4j
@Component
public class KthSyncScheduler {
 
    private static final int DELAY_MILLIS = 1000 * 60// 1 minute
 
    @Async
    @Scheduled(fixedRate = DELAY_MILLIS)
    @SchedulerLock(name = "KthSyncScheduler_sync", lockAtLeastFor = "59s", lockAtMostFor = "59s"// 한 인스턴스가 요청하는 1분 간 다른 인스턴스 요청 방지를 위해서 lockAtLeastFor, lockAtMostFor 를 설정
    public void sync() {
        log.info("폴링 job 시작 {}", LocalDateTime.now());
        
        // 폴링 로직 수행
 
        log.info("폴링 job 완료 {}", LocalDateTime.now());
    }
}
 
cs

 

@Async 를 이용하여 비동기 스레드풀에서 동작하도록 하였다. 

덕분에 해당 sync 메서드 수행이 다른 노드(스레드)에 영향을 미치지 않는다.

 

@Scheduled 에 폴링 주기를 설정해줄 수 있고,

@SchedulerLock 을 걸어주어 락 소유 최소시간, 락 소유 최대시간을 설정해줄 수 있다.

최대 시간은 폴링주기보다 살짝 짧게 하여, 폴링이 비동기적으로 영향없이 잘 설정되도록 하기 위함이고,

최소 시간 역시 폴링주기보다 살짝 짧게 하여, 혹시 모를 타 노드에서의 불필요한 폴링을 막도록 해주었다.

 

@Scheduled 옵션에 fixedRate 가 있는데, 이를 유심하게 살펴봐야한다.

fixedRate로 해주면 폴링 시작시점으로부터 N 밀리세컨드 마다 폴링을 수행하겠다는 의미이며,

fixedDelay로 할 경우 폴링이 끝난 시점으로부터 N 밀리세컨드마다 폴링을 수행하겠다는 의미이다.

 

fixedDelay로 하는 경우에는 해당 폴링 수행 시간이 다음 폴링에 영향을 미칠 수 있다고 판단하여 fixedRate를 선택했다.

fixedRate로 하는 경우, 해당 폴링 시간이 폴링주기보다 길어지는 경우에 제대로 동작하지 않을 수 있기 때문에 반드시 @Async 가 붙어있어야 한다

 

 

아래 블로그에 설명이 굉장히 잘 돼있어서 첨부한다.

https://seolin.tistory.com/123

 

Spring Boot - 스케줄러 사용해보기 1. FixedDelay vs FixedRate

들어가며 최근에 있었던 일입니다. 제가 만든 컴포넌트를 코드 리뷰를 통해 팀원들과 공유하는 자리를 가졌었는데, 스케줄링 된 작업에 대해서 이런 질문이 들어왔었습니다. 어라.. 저 작업이 0.

seolin.tistory.com


결과

 

폴링이 1분마다 잘 되고,

비동기 스레드풀의 여러 스레드에서 잘 수행되는 것을 확인할 수 있다.


참고

 

반응형