![[Spring] 다중 인스턴스에서 @Scheduled 문제와 ShedLock 사용하기](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb3t6qn%2FbtsIvxOgfq3%2F13dIlCggMMmELe3X58CKu0%2Fimg.jpg)
1. 실행 환경
- Java 17
- Gradle 8.5
- SpringBoot 3.2.3
2. 기존 코드
우리 프로젝트의 경우 게시물(Section)에 좋아요 기능과 관련한 API를 Redis를 사용하여 처리하고 있다. Redis에서 (key: SectionId, value:listof(userId))로 관리하고 있다.
30초 간격으로 스케줄링을 통해 Redis에 좋아요 정보를 DB의 Likes 테이블로 옮긴다.
// 30초마다 Redis 좋아요 기록을 DB의 Likes 테이블에 저장한다.
@Scheduled(fixedDelay = 1000L * 30)
@Transactional
public void saveLikes() {
log.info("스케줄링 호츌");
// 정규식에 해당하는 모든 key를 조회한다.
Set<String> keys = redisTemplate.keys("section:*:like");
if (keys == null || keys.isEmpty()) {
return;
}
for (String key : keys) {
// key에서 sectionId를 추출한다.
Long sectionId = Long.parseLong(key.split(":")[1]);
// key에 저장된 모든 value를 추출한다.
Set<String> userIds = redisTemplate.opsForSet().members(key);
for (String userId : userIds) {
User user = getUser(Long.parseLong(userId)); // 좋아요를 누른 사용자
Section section = getSection(sectionId); // 좋아요를 누른 회고 카드
// 새로운 좋아요 기록을 Likes 테이블에 저장 및 알림을 생성한다.
addLikeAndNotification(sectionId, user, section);
}
/**
* Redis의 Key에 저장되지 않은 value들을 Likes 테이블에서 삭제한다.
*/
List<Likes> likes = likesRepository.deleteBySectionIdAndUserIdNotIn(sectionId,
userIds.stream().map(Long::parseLong).toList());
likes.forEach(this::deleteNotification); // 알림을 지운다.
}
}
2. 두 대의 인스턴스 실행하기
8080 포트와 8081 포트를 사용하는 두 대의 인스턴스를 실행해보았다.
사진을 보면 한 가지 문제점이 존재하는데 “스케줄링 호출”이 두 번이 발생한다. Redis에 저장된 좋아요 기록을 DB로 옮기는 것은 1번이면 충분하다. Scale-Out이 커질수록 스케줄링이 중복으로 호출되는 횟수도 증가할 것이다.
3. ShedLock을 사용하여 스케줄링 Lock 걸기
3-1. ShedLock이란?
ShedLock이란 여러 대의 인스턴스가 존재할 때, 동일한 스케줄링이 중복으로 수행되지 않도록 Lock을 걸어주는 라이브러리이다. 예를 들어, 인스턴스 A, B가 존재할 때 A에서 스케줄링이 실행되면 Lock이 걸리기 때문에 B에서 동일한 스케줄링을 실행할 수 없다.
3-2. 테이블 생성
RDB를 사용하는 경우 ShedLock을 위한 테이블 생성이 필요하다.
- name: 스케줄링을 식별하기 위한 name
- lock_until: 잠금이 유효한 시간
- locked_at: 잠금이 설정된 시간
- locked_by: 잠금을 설정한
CREATE TABLE shedlock (
name VARCHAR(64),
lock_until TIMESTAMP(3) NULL,
locked_at TIMESTAMP(3) NULL,
locked_by VARCHAR(255),
PRIMARY KEY (name)
)
3-3. 의존성 추가
Java 17을 사용하는 경우 5.10.0 버전을 사용하고, 17 이전은 4.44.0 버전을 사용한다.
// shed lock
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.10.0'
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.10.0'
3-4. 설정 파일 생성
- @EnableSchedulerLock : ShedLock의 스케줄러 잠금 기능 활성화
- defaultLockAtMostFor : 잠금이 유지되는 최대 시간을 설정한다. 코드의 경우 “PT30S”를 설정했기 때문이 Lock이 30초 동안 유지된다. 30초 이후에는 Lock이 해제된다.
- LockProvider : ShedLock 인터페이스
- JdbcTemplateLockProvider : DB를 사용하는 경우 JdbcTemplateLockProvider를 사용한다.
- withJdbcTemplate() : JdbcTemplate을 사용하여 DB 작업을 수행한다.
- usingDbTime() : 서버의 시간이 아닌 DB의 시간을 사용하도록 설정한다. Lock을 DB 시간을 기준으로 사용하기 위해 사용된다.
import javax.sql.DataSource;
import net.javacrumbs.shedlock.core.LockProvider;
import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider;
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "PT30S")
public class SchedulerConfig {
@Bean
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime()
.build()
);
}
}
참고로 잠금 시간을 설정할 때 ISO 8601 표기법이 사용된다. PT15S는 Period of Time 15 Seconds를 의미한다.
- ISO 8601 시간 표기법
- H : 시간(hours)
- ex) PT1H : 1시간
- M : 분(minutes)
- ex) PT30M : 30분
- S : 초(seconds)
- ex) PT45S : 45초
- H : 시간(hours)
3-3. 비즈니스 로직 수정
기존의 비즈니스 로직에 @SchedulerLock 애너테이션을 사용한다. name 필드는 스케줄링 작업을 식별하기 위해 사용되는데, 여러 대의 서버에서 동일한 스케줄링을 수행하지 않도록 한다. 애너테이션에서 name 필드는 DB에서 식별자로 사용하는 필드명과 동일해야 한다.
// 30초마다 Redis 좋아요 기록을 DB의 Likes 테이블에 저장한다.
@Scheduled(fixedDelay = 1000L * 30)
@Transactional
@SchedulerLock(name = "SchedulerLock", lockAtLeastFor = "PT15S",lockAtMostFor = "PT30S")
public void saveLikes() {
log.info("스케줄링 호츌");
// 정규식에 해당하는 모든 key를 조회한다.
Set<String> keys = redisTemplate.keys("section:*:like");
if (keys == null || keys.isEmpty()) {
return;
}
for (String key : keys) {
// key에서 sectionId를 추출한다.
Long sectionId = Long.parseLong(key.split(":")[1]);
// key에 저장된 모든 value를 추출한다.
Set<String> userIds = redisTemplate.opsForSet().members(key);
for (String userId : userIds) {
User user = getUser(Long.parseLong(userId)); // 좋아요를 누른 사용자
Section section = getSection(sectionId); // 좋아요를 누른 회고 카드
// 새로운 좋아요 기록을 Likes 테이블에 저장 및 알림을 생성한다.
addLikeAndNotification(sectionId, user, section);
}
/**
* Redis의 Key에 저장되지 않은 value들을 Likes 테이블에서 삭제한다.
*/
List<Likes> likes = likesRepository.deleteBySectionIdAndUserIdNotIn(sectionId,
userIds.stream().map(Long::parseLong).toList());
likes.forEach(this::deleteNotification); // 알림을 지운다.
}
}
4. ShedLock을 사용하여 다중 인스턴스 실행
2개의 인스턴스를 사용하고 있으나 스케줄링은 한 개의 인스턴스에서만 실행되고 있다. Lock을 먼저 획득한 인스턴스에서 스케줄링이 실행된다.
특이한 점은 Lock 설정 및 해제를 반복하면서 새로운 row가 생기는 것이 아닌, 기존의 row에서 계속 update가 발생한다.
참고
https://dkswnkk.tistory.com/731
https://roopredev.tistory.com/4
https://minhye0k.github.io/shedlock-을-이용해-spring-scheduler-중복-실행을-멈춰보자