이전 포스트
https://2junbeom.tistory.com/205
[개발일지] spring batch를 통한 대량 이메일 발송 기능 개발 - 2 (AWS SES로의 마이그레이션, 적절한 스
https://2junbeom.tistory.com/204이전 포스트 [개발일지] spring batch를 통한 대량 이메일 발송 기능 개발 - 1 (멀티 스레딩 동시성 문제)spring batch 도입현재 진행 중인 프로젝트에서 이메일을 대량으로 발송
2junbeom.tistory.com
분산락 도입 과정
우리 서비스는 여러 대의 서버가 작동하는 상황이다.
새벽 3시에 자동으로 이메일 전송을 하게 만드려면 @Scheduled 어노테이션을 활용해야 한다.
그러나 서버 여러 대에 모두 @Scheduled 어노테이션이 걸려있기 때문에 분산락을 도입해서
한 대의 서버에서만 이메일 전송 로직이 실행되도록 만들기로 했다.
Shed Lock이란?
가볍게 우리가 현재 사용중인 redis를 활용해 분산락을 만드려고 했다.
분산락을 찾아보던 중 shed lock이란 것을 발결하게 됐다.
shed lock은 분산 환경에서 스케줄링된 작업이 여러 서버에서 동시에 실행되지 않도록 막기위해
사용되는 락 라이브러리이다.
작업 전 redis에 락을 걸고 (insert into lock_table)
해당 락을 얻은 서버만 작업을 실행하도록 도와준다.
작업 이후 락을 해제한다.
우선 레디스에 키 밸류 쌍으로 다음과 같이 저장된다.
- SET emailJobLock "server-1" NX PX 600000
- "emailJobLock" : 락의 이름 (고유 key)
- "server-1" : 락을 소유한 서버 식별자 (hostName 등)
- NX : 키가 없을 때만 설정 (기존에 락이 있으면 무시)
- PX 600000 : 600,000ms (10분) 후 락 자동 만료 (TTL, 안전장치)
- 여기서 NX를 통해 Key를 먼저 얻지 못한 서버에서는 작동을 못하게 만드는 원리이다.
Shed Lock 흐름 정리
- 락 시도
- SET my-job-lock server-1 NX PX 10000
- 성공시 해당 server-1에서 코드 실행
- 실패시 서버 작업 패스
- SET my-job-lock server-1 NX PX 10000
- 작업이 끝나면 락 삭제
의존성
// redis shedlock
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.16.0'
implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:5.16.0'
코드 분석
이제 코드를 살펴보자
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT10M") // 락 유지 시간 (10분)
public class RedisShedLockConfig {
@Bean
public LockProvider lockProvider(RedisConnectionFactory redisConnectionFactory) {
return new RedisLockProvider(redisConnectionFactory);
}
}
@EnableScheduling으로 스케쥴러가 동작하게 해주었고
@EnableSchedulerLock(defaultLockAtMostFor = "PT10M")
을 통해 shed lock을 활성화 해주었다.
defaultLockATMostFor은 락의 최대 유지 시간을 설정하는 메서드다
10분으로 설정해주었다.
LockATMostFor 값은 레디스의 PX 값에 설정된다.
@Scheduled(cron = "0 0 3 * * *") // 매일 새벽 3시
@SchedulerLock(name = "emailNotificationJobLock", lockAtMostFor = "PT10M") // 락 10분간 유지
public void executeIfScheduled() {
String today = LocalDate.now().format(DateTimeFormatter.ISO_DATE);
String key = "email_batch_" + today;
if (checkReserved(key)) {
return; // 예약된 작업이 없으면 실행 안 함
}
try {
JobParameters params = new JobParametersBuilder()
.addLong("time", System.currentTimeMillis())
.toJobParameters();
jobLauncher.run(emailNotificationJob, params);
log.info("신규 지원 이메일 배치 작업 실행 완료");
} catch (Exception e) {
log.error("신규 지원 이메일 배치 작업 실행 실패", e);
throw new ApplyEmailBatchJobFailException();
} finally {
redisUtil.delete(key); // 중복 실행 방지
}
}
지금까지 작성했던 배치 코드를 실행시키는 코드 부분이다.
@SchedulerLock(name = "emailNotificationJobLock", lockAtMostFor = "PT10M") // 락 10분간 유지
name은 레디스에 들어갈 key값을 의미한다.
lockAtMostFor은 아까 말했던 TTL설정 값이다.
인자가 하나 더 숨어있긴 하다.
LockAtLeastFor이라는 인자인데, 이 인자는 락이 너무 빨리 해제될 시 작업이 중복해서 실행될 수 있기 때문에
그를 막기 위한 최소 락 유지 시간을 의미한다.
내 이메일 대량 발송 로직은 시간이 오래 걸리기에 LockAtLeastFor 인자는 설정해주지 않았다.
운영진의 이메일 대량 발송 요청
우리 사이트의 로직은 다음과 같다.
운영진이 이메일 대량 발송 요청 보냄 -> 레디스에 해당하는 날짜의 키 밸류를 설정
레디스 스케쥴링이 실행됐을 때 오늘 날짜의 키 밸류 값이 있는지 확인 후
운영진이 요청을 보낸 것이 확인 됐으면 로직 실행
과 같은 순서다.
private Boolean checkReserved(String key) {
Boolean scheduled = redisUtil.hasKey(key);
if (!Boolean.TRUE.equals(scheduled)) {
return true; // 예약된 작업이 없으면 실행 안 함
}
return false;
}
위와 같은 코드로 운영진이 이메일 대량 발송 요청을 보냈는지 체크하고 스케쥴러를 실행시킨다.
스케쥴러는 매일 실행되는 코드기 때문에 위의 검증 작업이 꼭 필요하다.
여기서 문제가 하나 발생하는데
스케쥴러는 새벽 3시에 실행된다.
즉, 00:00 ~ 02:59에 들어온 요청은 오늘 날짜로 레디스에 저장하면 되고,
03:01 ~ 이후에 들어온 요청은 다음 날 새벽 03:00에 실행되는 것이니 레디스에 날짜를 하루 추가해서 저장해주면 된다.
if (now.isBefore(LocalTime.of(3, 0))) {
targetDate = LocalDate.now(); // 오늘 03:00 예약
} else {
targetDate = LocalDate.now().plusDays(1); // 내일 03:00 예약
}
이렇게 shed lock을 활용한 간단한 동시성 제어 코드를 작성해 보았다.
미구현된 부분
아직 메일 발송에 실패한 메일들에 대한 처리가 어떻게 이뤄질지 정해지지 않았다.
내가 생각하는 건 DLQ를 만들고 이메일 전송에 실패한 메일들만 전부 DLQ로 보내는 것이다.
context -> {
item.changeStatus(EmailStatus.FAILED);
failedItems.add(item);
log.error("DLQ 처리 - {}: {}", item.getEmail(),
Objects.requireNonNull(context.getLastThrowable()).getMessage());
return null;
});
} catch (Exception e) {
item.changeStatus(EmailStatus.FAILED);
failedItems.add(item);
log.error("Unexpected failure: {}", item.getEmail(), e);
}
writer의 위 부분은 지수 백오프 3회 재시도 이후, 실패한 행에 대한 처리가 이뤄지는 부분인데
위의 코드에서 DLQ 전송 후 삭제 로직을 추가하고
JobLauncher.run 이후에 모든 행을 전부 삭제하는 로직만 추가하면 안정적으로 모든 email을 처리할 수 있을 것이라 생각한다.
'☃️❄️개발일지, 트러블슈팅❄️☃️' 카테고리의 다른 글
| [개발일지] spring batch를 통한 대량 이메일 발송 기능 개발 - 2 (AWS SES로의 마이그레이션, 적절한 스레드 개수와 청크 사이즈 계산, 쿼리 최적화, 스레드 동적 생성) (1) | 2025.05.28 |
|---|---|
| [개발일지] spring batch를 통한 대량 이메일 발송 기능 개발 - 1 (멀티 스레딩 동시성 문제) (0) | 2025.05.28 |
| [트러블 슈팅] 배포 후 webp 확장자 변환 에러 (GLIBC_2.29 'not found') (0) | 2025.03.30 |
| [개발일지] Fetch join을 통한 N+1 문제 해결 (0) | 2025.03.18 |
| [개발일지] 시스템 아키텍처 설계 (0) | 2025.01.21 |