☃️❄️개발일지, 트러블슈팅❄️☃️

[개발일지] spring batch를 통한 대량 이메일 발송 기능 개발 - 3 (redis Shed lock, @Scheduled, 분산락)

대기업 가고 싶은 공돌이 2025. 5. 28. 17:29

이전 포스트

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 흐름 정리

  1. 락 시도
    • SET my-job-lock server-1 NX PX 10000
      • 성공시 해당 server-1에서 코드 실행
      • 실패시 서버 작업 패스
  2. 작업이 끝나면 락 삭제

 

의존성

// 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을 처리할 수 있을 것이라 생각한다.