2025.02.21 - [팀 프로젝트/최종 프로젝트] - DB에 데이터 랜덤하게 삽입하기 (1) 커스텀 스케줄 어노테이션 구현
DB에 데이터 랜덤하게 삽입하기 (1) 커스텀 스케줄 어노테이션 구현
현재 진행중인 프로젝트는 '채용 공고를 한 곳에서 모아보는 사이트' 이다.최대한 실제 사용환경처럼 구축해보고싶어서 DB에 랜덤한 채용공고 데이터를 삽입하는 기믹이 필요했고@Scheduled 어노
roqkfchqh.tistory.com
이어지는 글입니당
문제
public class JobOpeningInsertScheduler {
private final JobOpeningRepository jobOpeningRepository;
private final JobOpeningKeywordRepository jobOpeningKeywordRepository;
private final Random random = new Random();
private static final int JOB_COUNT = 3; //한 번에 생성할 최대 jobOpening 개수
/**
* minMinutes - maxMinutes 사이의 랜덤한 시간 간격을 두고 랜덤한 채용공고를 삽입합니다.
* 한 번에 최소 1 - 최대 JOB_COUNT 개의 데이터를 생성합니다.
*/
@ScheduledDynamic(minMinutes = 3, maxMinutes = 40)
public void insertRandomJobOpening() {
log.info("랜덤 채용공고 데이터 삽입 시작");
int jobCount = random.nextInt(JOB_COUNT) + 1;
log.info("{}개의 채용 공고 삽입 예정", jobCount);
List<JobOpening> jobOpenings = new ArrayList<>();
List<JobOpeningKeyword> allJobOpeningKeywords = new ArrayList<>();
for (int i = 0; i < jobCount; i++) {
JobOpening jobOpening = JobOpeningFactory.createRandomJobOpening();
jobOpenings.add(jobOpening);
List<JobOpeningKeyword> jobOpeningKeywords = JobOpeningKeywordFactory.createRandomKeywordList(jobOpening);
allJobOpeningKeywords.addAll(jobOpeningKeywords);
}
jobOpeningRepository.saveAll(jobOpenings);
jobOpeningKeywordRepository.saveAll(allJobOpeningKeywords);
log.info("총 {}개의 채용 공고가 삽입되었습니다.", jobOpenings.size());
}
}
채용공고 데이터를 랜덤으로 만들고, 랜덤한 시간 간격을 두고 자동으로 삽입시켜주는 스케줄러 코드다.
현재 이 코드에는 꽤 심각한 문제가 있다. 바로 동기화 문제가 해결되지 않은 상태란 거다
우리 서버는 업데이트(배포)시에 무중단 배포 전략으로 롤링 업데이트를 사용중이고, ec2 오토스케일링 그룹에서 직접 관리해준다.
평상시엔 원하는 용량(1)로 단일 인스턴스로 관리되지만 배포할 때 짧은 순간에 구버전 서버와 신버전 서버가 함께 존재하는데
이 때 스케줄러에 의해 같은 태스크가 두 번 실행된다면?
로컬에서 도커로 서버를 두 개 띄워서 실험해보자.
당연하게도 app1에서 실행된 스케줄러의 1개 데이터와, app2에서 실행된 스케줄러의 2개 데이터가 합쳐져서
9분 7초에 총 3개의 데이터가 삽입된 걸 볼 수 있다.
지금은 물론 랜덤으로 데이터를 삽입하기에 오히려 좋다(?) 거나 별 문제가 없다고 느껴질 수 있다.
하지만 만약 실제 크롤링 한 정보라고 생각한다면 꽤나 심각한 문제다. 그러므로 지금부터 다중 인스턴스 환경에서 스케줄러가 한 번만 호출되도록 보장해보자
고민
일반적인 락 기법으론 제어할 수 없다.
안정적인 순차 실행을 원하는 게 아니라, 두 개 이상의 인스턴스에서 같은 스케줄러가 아예 실행되지 않도록 하는 게 목표기 때문이다.
어떻게 하면, '이미 한 인스턴스에서 스케줄러가 실행 중' 일때 '다른 인스턴스에선 스케줄링 자체를 막을' 수 있을까
락을 조금 다른 방법으로 일종의 캐시처럼 사용해보자.
현재 실행중인 인스턴스 정보를 가져와서 락의 value로 사용해서 다른 인스턴스의 락이 먼저 점유중이면 예외를 반환하고 해당 인스턴스에서의 스케줄링은 실행되지 않도록 제어하는거다.
DB또는 Redis로 구현할 수 있는데, Redis가 TTL을 통해 휘발성 정보를 담는 데는 강력하니까 Redis로 구현하자.
Redis로 구현
public class InstanceUtil {
public static String getInstanceId() {
return ManagementFactory.getRuntimeMXBean().getName();
}
}
구현은 Redis의 Lettuce로 했다. 일반적인 분산락과 다르기 때문에 Redisson을 사용할 이유가 없다.
현재 인스턴스 정보를 가져올 수 있게 getInstanceId()메서드를 만들고 key는 이 스케줄러의 고유 키, value를 인스턴스 정보로 사용해
1. 락을 잡으려 시도
2. 락을 못잡았다면 내 인스턴스의 락인지 확인
3. 내 인스턴스의 락이 아니라면(다른 인스턴스의 소유라면) 리턴
4. 내 인스턴스의 락이라면, TTL 재갱신
의 로직을 구현했다.
redis에 이렇게 인스턴스의 정보가 value로 저장된걸 볼수있었고
한번 락을 놓친 스케줄러는, 지정한 TTL이 끝날 때 까지 스케줄링 예약만 하게 된다.
이제 다중 인스턴스 환경에서도 하나의 스케줄러만 돌아가도록 만들 수 있었다!
SRP 또는 캡슐화
다른 스케줄링 로직에도 사용할 수 있게 하고싶다.
변할 수 있는 값과 불변값을 분리해서 편하게 가져다 쓸 수 있게 만들자.
public class SchedulerLockUtil {
private final RedisTemplate<String, String> redisTemplate;
private static final String LOCK_VALUE = InstanceUtil.getInstanceId();
public void lock(String keyName, long ttl) {
Boolean acquired = redisTemplate
.opsForValue()
.setIfAbsent(keyName, LOCK_VALUE, ttl, TimeUnit.MINUTES);
if (Boolean.FALSE.equals(acquired)) {
String currentValue = redisTemplate.opsForValue().get(keyName);
if (!LOCK_VALUE.equals(currentValue)) {
throw new IllegalStatusException(ServerErrorCode.ALREADY_RUNNING_SCHEDULER);
}
redisTemplate.expire(keyName, ttl, TimeUnit.MINUTES);
}
}
}
keyname과 ttl이 스케줄러 별로 다를 것이므로 분리해줬다.
기존엔 같은 스케줄러 코드 내부에서 돌아갔어서 간단하게 return;으로 처리했는데,
메서드가 분리되었으니 커스텀exception을 만들어서 예외를 반환하도록 했다.
원래의 스케줄러 코드에서 schedulerLockUtil을 호출하고
try-catch로 감싸 예외 발생 시 스케줄러를 종료하도록 리팩토링 했다
잘된다
'팀 프로젝트 > 최종 프로젝트' 카테고리의 다른 글
JWT Payload 암호화하기(AES) (0) | 2025.02.23 |
---|---|
DB에 데이터 랜덤하게 삽입하기 (1) 커스텀 스케줄 어노테이션 구현 (0) | 2025.02.21 |
IP와 Email 차단을 DB로 옮기고 IP 전역 밴 추가하기 (0) | 2025.02.20 |