글 작성자: beaniejoy
  • 사용 배경
  • 문제 인식
    - 1. outbox 테이블 전체 조회로 인한 문제
    - 2. scale-out 상황에서 스케줄러 작업의 중복 실행의 가능성 문제
  • ShedLock을 위한 프로젝트 설정
  • ShedLock을 이용한 스케줄링 Lock 설정
    - Scale-out 상황에서 스케줄러 중복 실행에 대한 해결
  • 전체 조회 내용을 chunk로 나누어 작업 수행
    - 처음에 작은 단위로 조회해보기
    - 비동기 방식으로 메일 전송 작업하기
  • 끝나지 않은 고민
    - 작업을 꼭 수행해야하는 기능에 대해서는 어떻게 구현할 것인가

 

📌 1. 사용 배경

프로젝트를 진행하면서 패스워드 변경 요청을 위한 Email 전송 기능이 필요해졌습니다. Email은 spring boot starter mail 라이브러리를 사용하여 테스트용도로 개발을 진행하였습니다.

// main sender
implementation 'org.springframework.boot:spring-boot-starter-mail'

또한 메일 전송 기능을 위해 OutBox 패턴을 이용해 Eventual Consistency한 방향으로 개발을 하였습니다.
(이전에 해당 내용에 대해 작성했던 게시글이 있는데 참고하시면 될 것 같습니다. Outbox 패턴을 이용한 메일 발송 구현해보기)

 

Outbox 패턴을 이용한 메일 발송 구현해보기

Github Repo https://github.com/beaniejoy/resetpw-outbox-scheduler Overview Eventual Consistency & Strong Consistency At Least Once? Outbox 패턴 코드 구현 📌 Overview 프로젝트 개발 중에 비밀번호 초기..

beaniejoy.tistory.com

이를 위해 Spring Scheduler를 통해 crontab 기능을 적용하여 배치처럼 주기적으로 OutBox 테이블을 참조하여 자동으로 메일을 전송하도록 구성하였습니다. 여기서 문제에 직면하게 됩니다.

  • outbox 테이블 전체 조회로 인한 문제
  • scale-out 상황에서 스케줄러 작업의 중복 실행의 가능성 문제

 

🔖 1-1. outbox 테이블 전체 조회로 인한 문제

저번 게시물에서 간단하게 구현해놓은 스케줄러 로직은 outbox 테이블 내용을 전체 조회하여 이메일 전송하고 전부 전송 작업을 완료하였을 때 한 꺼번에 해당 event들을 삭제하도록 설계하였습니다.

List<OutBox> outBoxList = outBoxRepository.findAll();

//...

if(!outBoxCompletedList.isEmpty())
            outBoxRepository.deleteAllByOutBoxId(outBoxCompletedList);

여기서 10초마다 해당 메소드를 실행하도록 스케줄러 설정을 하였다면 다음과 같은 상황이 발생할 수 있습니다.

Event 1 ~ 10 조회
sending mail...

... Scheduler 종료

// Event 1 ~ 10에 대해서 데이터 삭제 -- 실행 X

이메일 하나 전송하는 시간에 대한 delay가 있어서 조회한 이벤트에 대해서 이메일 전송하는 중에 스케줄러가 종료가 되어버립니다. 이렇게 되면 이미 이메일 전송완료했던 이벤트들이 다음 스케줄러에서 또 조회가 되어 중복으로 이메일이 전송이 되는 문제가 발생합니다.

 

🔖 1-2. scale-out 상황에서 스케줄러 작업의 중복 실행의 가능성 문제

Event 1 ~ 10 조회 -- Scheduler 1.
sending mail... -- Scheduler 1.

Event 1 ~ 10 조회 -- Scheduler 2.
sending mail... -- Scheduler 2.

Event 1 ~ 10에 대해서 데이터 삭제 -- Scheduler 1.
Event 1 ~ 10에 대해서 데이터 삭제 -- Scheduler 2.

1번째 스케줄러가 1 ~ 10 event를 조회해 이메일을 전송하는 동안 scale-out 한 다른 어플리케이션 서버에서 구동중인 2번째 스케줄러가 동시에 이메일 전송 작업을 진행한다면 위의 내용처럼 중복으로 이메일을 전송하게 되는 문제가 발생합니다.

 

📌 2. 문제 인식

물론 At Least Once (최소 한 번 이상)를 구현한다는 관점에서 위의 두 문제점들은 문제가 아닌 것으로 보일 수 있습니다.
어차피 최소 한 번 이상 이메일이 전송되기만 하면 되기 때문에 중복으로 발송되는 것은 논외인 셈이죠.

Event 1 ~ 10 조회
sending mail...

// Event 1 ~ 10에 대해서 데이터 삭제 -- 실행 X

// Event 10부터 이메일 발송 이벤트가 쌓여도 
// 1 ~ 10에 대해서만 전송이 이루어지고 그 이후에는 이메일 전송 X

하지만 불필요하게 이메일을 중복으로 전송하는 것 자체도 자원낭비이고 scale-out 상황에서 스케줄러 자체의 중복이 발생하는 것도 여러 어플리케이션 서버를 운영하는 환경에서 자칫하면 똑같은 내용의 데이터만 메일을 전송할 수 있다는 점에서 확실히 문제라고 생각했습니다

그래서 이 문제들을 스케줄러 Lock을 이용해 해결해보려고 합니다.

 

📌 3. ShedLock을 위한 프로젝트 설정

스케줄러 Lock을 위해 해당 기능을 제공해주는 ShedLock 라이브러리를 사용해봤습니다. ShedLock에 대한 설명은 구글링해도 충분히 나오기 때문에 간략하게 프로젝트에 설정한 내용들만 언급하고 넘어가려 합니다.

// build.gradle
buildscript {
    ext {
        shedlockVersion = [ShedLock 버전 적용]
    }
}
// shedlock
implementation "net.javacrumbs.shedlock:shedlock-spring:${shedlockVersion}"
implementation "net.javacrumbs.shedlock:shedlock-provider-jdbc-template:${shedlockVersion}"

ShedLock에 대한 Github Repository

 

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

CREATE TABLE `shedlock` (
    `name` VARCHAR(64) NOT NULL,
    `lock_until` TIMESTAMP(3) NOT NULL,
    `locked_at` TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    `locked_by` VARCHAR(255) NOT NULL,
    PRIMARY KEY (`name`)
);

shedlock github repo에서 제공하고 있는 shedlock 관련 DB 테이블 DDL입니다.

스케줄링 자체에 lock을 걸고 구분하기 위해 테이블에 스케줄링 관련 정보를 담고 여기에 담긴 내용을 기준으로 해당 스케줄링 대상이 되는 작업에 접근 가능 여부를 판단하게 됩니다.

@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10s")
public class SchedulerConfig {

    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(
                JdbcTemplateLockProvider.Configuration.builder()
                        .withJdbcTemplate(new JdbcTemplate(dataSource))
                        .usingDbTime()      // UTC time based on the DB server clock
                        .build()
        );
    }
}

ShedLock 관련 Config 파일입니다. defaultLockAtMostFor내용은 기본값으로 세팅한 값이고 각 스케줄링 메서드마다 @SchedulerLock을 통해 스케줄링 작업마다 따로 설정할 수 있습니다.

 

📌 4. ShedLock을 이용한 스케줄링 Lock 설정

@Scheduled(cron = "*/15 * * * * *")
@SchedulerLock(
    name = "scheduledSendingEmailTask", 
    lockAtLeastFor = "14s", 
    lockAtMostFor = "14s")
public void schedulingResetPasswordMail() {
    List<ResetKeyBox> resetKeyBoxList = resetKeyBoxRepository.findAll();

    resetKeyBoxProcessor.processEachBoxesAndSendMail(resetKeyBoxList);
}

이메일 발송에 대해서 스케줄링 주기를 크게 잡고 싶지 않아 15초로 주기를 설정하였고 @SchedulerLock을 통해 ShedLock 기능을 사용하였습니다. 해당 어노테이션에는 크게 세 개의 옵션내용을 설정하면 됩니다.

  • name: 스케줄러 Lock을 걸려면 어떤 스케줄러 작업이 Lock 걸려있는지 키값이 필요하겠죠. 이를 구분해주기 위해 설정하는 내용입니다. name에 들어가는 내용은 스케줄러가 돌았을 때 shedlock 테이블에 데이터로 insert 되어 다른 스케줄러가 이를 참조해 같은 이름의 스케줄러 작업을 제한하는 구분값으로 사용됩니다.
  • lockAtLeastFor: 실질적으로 lock이 걸리는 최소 보장된 lock 시간입니다.
  • lockAtMostFor: 해당 스케줄러 노드가 다운되었을 때 언제까지 lock을 보장해주는지에 대한 최대 시간 설정입니다.

여기서 lockAtLeastFor 보다 lockAtMostFor 을 더 작은 시간으로 설정하면 다음과 같은 에러가 발생하게 되니 이 점 유의해야 합니다.

java.lang.IllegalArgumentException:   
lockAtLeastFor is longer than lockAtMostFor for lock 'scheduledSendingEmailTask'.

여기서는 그냥 같은 시간으로 설정하였습니다.

 

🔖 4-1. Scale-out 상황에서 스케줄러 중복 실행에 대한 해결

ShedLock 설정 전에 프로젝트를 실행했을 때는 2대의 애플리케이션 서버에서 해당 스케줄러가 거의 동시에 실행되는 것을 볼 수 있었습니다.

ShedLock 설정 후에 실행해보니 하나의 서버에서만 스케줄러가 실행이 되었고 다른 한쪽에서는 실행이 안되는 것을 확인할 수 있었습니다. 이로써 위에서 언급했던 내용 중에 두번째 문제에 대해서 해결할 수 있었습니다.

 

📌 5. 전체 조회 내용을 chunk로 나누어 작업 수행

첫 번째 문제였던 전체 조회로 인한 문제점을 해결하고자 chunk 단위로 나누어 설계를 해보았습니다.

if (!resetKeyBoxList.isEmpty()) {
    int totalSize = resetKeyBoxList.size();
    int numberOfCompleted = 0;
    List<Long> completedBoxesList = new LinkedList<>();
    for (ResetKeyBox resetKeyBox : resetKeyBoxList) {
        ContentDto mailContent = mailContentService.createContent(
                resetKeyBox.getResetKey(),
                resetKeyBox.getEmail(),
                resetKeyBox.getUserName()
        );

        try {
            // 발송완료시 ResetKeyBox 단위별로 mail 전송 및 table delete 처리
            sendMailService.sendMail(mailContent);
            logger.info("#" + resetKeyBox.getResetKeyBoxId() + " ResetKeyBox에 대한 메일 발송 완료하였습니다.");

            completedBoxesList.add(resetKeyBox.getResetKeyBoxId());
            numberOfCompleted++;

            if (numberOfCompleted == 10 || numberOfCompleted == totalSize) {
                resetKeyBoxRepository.deleteBoxesWithIds(completedBoxesList);
                completedBoxesList.clear();
                totalSize -= numberOfCompleted;
                numberOfCompleted = 0;
            }
        } catch (MailException e) {
            logger.error("MailSender process 과정에서 문제가 발생하였습니다. Box Id: " + resetKeyBox.getResetKeyBoxId());
        } catch (RuntimeException e) {
            logger.error("#" + resetKeyBox.getResetKeyBoxId() + " ResetKeyBox 처리하는 과정에서 예기치 않은 에러가 발생하였습니다.");
        }
    }
}

한 번 스케줄러 실행할 때마다 10개(임의로 지정 가능) 단위로 outbox 테이블 내의 데이터를 삭제하도록 수정해보았습니다.

@Scheduled(cron = "*/30 * * * * *")
@SchedulerLock(
    name = "scheduledSendingEmailTask", 
    lockAtLeastFor = "29s", 
    lockAtMostFor = "29s")

10개 마다 삭제 작업이 실행될 수 있도록 메일 발송에 대해 충분히 여유를 두고자 30초 단위로 스케줄러 주기를 늘렸습니다.
이렇게 설계하고 프로그램을 실행하면 처음에는 작업이 잘 수행되는 것처럼 보이다가 다시 중복 전송 문제가 발생했습니다.

Event 1 ~ 20 조회

(반복 1번째)
sending mail... about Event 1 ~ 10
delete Event 1 ~ 10 완료

(반복 2번째)
sending mail... about Event 11 ~ 20

... Scheduler 종료

이런 상황에서는 Event 11 ~ 20 데이터가 삭제 조치가 안되었기 때문에 다음 스케줄러 작업에서 중복으로 이메일이 전송되는 문제가 여전히 발생하게 됩니다.

 

🔖 5-1. 처음에 작은 단위로 조회해보기

@Scheduled(cron = "*/30 * * * * *")
@SchedulerLock(
    name = "scheduledSendingEmailTask", 
    lockAtLeastFor = "29s", 
    lockAtMostFor = "29s")
public void schedulingResetPasswordMail() {
    List<ResetKeyBox> resetKeyBoxList = resetKeyBoxRepository.findAll();
    // 리스트 결과값 기준으로 not empty
    resetKeyBoxProcessor.processEachBoxesAndSendMail(resetKeyBoxList);
}

여기서 findAll로 인해 작업단위가 커져서 30초의 스케줄러 주기안에 모든 이벤트들을 이메일 전송하기 힘들어보입니다.

@Query(value = "SELECT * FROM reset_key_box LIMIT 10")
public List<ResetKeyBox> findAllByLimit();

우선 10개 단위로 작업을 진행해보았을 때는 작업이 잘 완료된 것을 확인하였습니다.

그런데 이것도 좀 아쉬웠습니다. 작업단위를 10으로 했을 때 메일 전송 작업이 더디게 진행될 것이기에 만약 실제 수천, 수만명의 사용자들을 대상으로 동시에 수천건의 이메일 발송을 처리해야 하는 상황에서 한 번 패스워드 찾기 이메일 전송을 요청했을 때 이메일 내용을 한 참 뒤에 확인해야 하는 불편이 생길 수 있습니다.

 

🔖 5-2. 비동기 방식으로 메일 전송 작업하기

메일 전송하는 과정에서 많은 시간 비용이 발생하기 때문에 이 작업 자체를 비동기 방식으로 처리한다면 스케줄러 작업 속도를 더 빨리 진행할 수 있을 것 같습니다.

@Async
public void sendMail(ContentDto mailSendDto) {
    SimpleMailMessage message = new SimpleMailMessage();
    message.setTo(mailSendDto.getTo());
    message.setSubject(mailSendDto.getTitle());
    message.setText(mailSendDto.getMessage());

    mailSender.send(message);
}

위와 같이 메일 발송에 대해서 비동기 처리 설정을 하였고 스케줄러 작업을 수행해본 결과 제가 원하는 스케줄러 주기 안에 수십개씩 메일 발송을 처리가 진행된 것을 확인할 수 있었습니다.

// reset_key_box 테이블에서 수십개씩 원하는 만큼 데이터 조회
// 여기서는 10초의 스케줄러 주기 동안 30개씩 수행해보는 것으로 테스트

// ...

try {
    // 메일 발송에 대해서 비동기 처리
    sendMailService.sendMail(mailContent);
    logger.info("#" + resetKeyBox.getResetKeyBoxId() + " ResetKeyBox에 대한 메일 발송 완료하였습니다.");

    completedBoxesList.add(resetKeyBox.getResetKeyBoxId());
    numberOfCompleted++;

    if (numberOfCompleted == 10 || numberOfCompleted == totalSize) {
        resetKeyBoxRepository.deleteBoxesWithIds(completedBoxesList);
        completedBoxesList.clear();
        totalSize -= numberOfCompleted;
        numberOfCompleted = 0;
    }
} catch (MailException e) {
    logger.error("MailSender process 과정에서 문제가 발생하였습니다. Box Id: " + resetKeyBox.getResetKeyBoxId());
} catch (RuntimeException e) {
    logger.error("#" + resetKeyBox.getResetKeyBoxId() + " ResetKeyBox 처리하는 과정에서 예기치 않은 에러가 발생하였습니다.");
}

이렇게 하니 시간이 오래걸리는 메일 발송 자체에 대해 별개의 작업(비동기 처리)으로 진행하고 10개씩 chunk로 메일 전송된 데이터들을 지우는 방식으로 해서 reset_key_box 내의 데이터가 잘 삭제되었습니다.

또한 제가 원하는 10초의 짧은 스케줄러 주기안에 하나의 메일 전송 작업단위를 완벽하게 끝낼 수 있어서 다음 스케줄러에서 중복으로 메일 전송되는 일이 발생하지 않게 되었습니다.

 

📌 6. 끝나지 않은 고민

사실 여기까지만 해도 패스워드 변경관련 메일 전송에 대해서 어느정도 작업을 잘 수행한다고 할 수 있습니다. 하지만 기존에 ALO(At Least Once) 방식과는 거리가 멀어지고 AMO(At Most Once) 방식에 더 가까워졌다고 할 수 있습니다.

try {
    // 메일 발송에 대해서 비동기 처리
    sendMailService.sendMail(mailContent); // 여기서 메일 발송 에러가 발생한다면?

        // ...

    // 데이터 삭제 작업
} catch (MailException e) {
    logger.error("MailSender process 과정에서 문제가 발생하였습니다. Box Id: " + resetKeyBox.getResetKeyBoxId());
} catch (RuntimeException e) {
    logger.error("#" + resetKeyBox.getResetKeyBoxId() + " ResetKeyBox 처리하는 과정에서 예기치 않은 에러가 발생하였습니다.");
}

sendMail에 대해서 비동기 처리로 작업을 진행하였기 때문에 사실 메일 전송이 성공했는지 실패했는지 결정되기도 전에 전송 이벤트 데이터가 삭제가 됩니다. 메일 전송 성공 유무와 관계없이 전송 완료된 것으로 보고 데이터가 삭제된 것이라 할 수 있습니다.

그래서 엄밀히 말하면 최소 한 번 메세지 전송처리를 보장한다는 At Least Once 개념과는 거리가 멀고 최대 한 번 메세지 전송한다는 At Most Once 개념에 가까워졌다고 할 수 있습니다.

실제 서비스하고 있는 웹사이트를 가보면 패스워드 찾기에서 메일 전송은 사실 성공유무에 관심이 없습니다. 우리가 패스워드를 잊어버려서 패스워드 찾기를 위해 이메일 발송 버튼을 눌렀을 때 메일이 안와서 다시 발송 버튼을 누른 경험이 한 두번은 있을 것입니다.

이번에 위에서 작성한 코드 방식대로 구현한다고 했을 때 실제 서비스하고 있는 방식과 크게 다르지 않다고 할 수 있습니다.
(모두 At Most Once 관점으로 적용)

 

🔖 6-1. 작업을 꼭 수행해야하는 기능에 대해서는 어떻게 구현할 것인가

메일 발송, 인증을 위한 인증번호 문자 전송 등에 대해서는 위와 같은 개념으로 접근해도 됩니다. 재발송 버튼을 제공함으로써 문자 전송했는데 매세지가 안오는 상황에 대해 대비해놓은 것을 우리는 경험으로 알 수 있습니다.

그런데 작업을 꼭 수행해야하는 스케줄러 작업에 대해서는 위의 ShedLock과 polling 방식을 구현하는 방법으로는 한계가 많다고 느꼈습니다.

이러한 기능들에 대해서는 KafkaRabbitMQ와 같은 메시지 큐 방식의 처리가 필요해보입니다.
우선 kafka에 대해서 공부해보고 프로젝트에도 적용해볼 생각입니다.
(이와 관련해서도 블로그에 게시글 올려보겠습니다!)