포스트

Spring Batch 이메일 발송 배치 - (실전 구현 예제)

Spring Batch 이메일 발송 배치 - (실전 구현 예제)

Reader, Processor, Writer를 활용한 실무 배치 개발을 다뤄보겠습니다.


Sample Code

이 시리즈에서 다루는 전체 예제 코드는 GitHub에서 확인할 수 있습니다.

spring-batch-sample


시리즈 목차


이 글에서 다룰 내용

  • 실제 이메일 발송 배치 구현
  • Reader/Processor/Writer 상세 분석
  • 성공/실패 처리 및 상태 업데이트
  • Skip과 Retry 전략
  • 트랜잭션 관리
  • 테스트 및 디버깅

구현할 배치 시스템 아키텍처

전체 흐름도

flowchart TB
    subgraph Job["Email Send Batch Job"]
        subgraph Step["Email Send Step"]
            subgraph Chunk["Chunk (1000건씩 처리)"]
                Reader["Reader<br/>DB에서 PENDING 상태 이메일 조회"]
                Processor["Processor<br/>이메일 주소 검증<br/>EmailService.send() 호출<br/>성공/실패 상태 설정"]
                Writer["Writer<br/>발송 결과를 DB에 Batch Update"]
                Commit["COMMIT"]
                Reader --> Processor --> Writer --> Commit
            end
        end
    end

프로젝트 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
src/main/java/com/example/batch/
├── job/
│   ├── EmailSendJobConfiguration.java    # Job 설정
│   └── listener/
│       └── EmailJobExecutionListener.java # Job 리스너
├── entity/
│   └── Email.java                         # 이메일 엔티티
├── repository/
│   └── EmailRepository.java               # JPA Repository
├── service/
│   ├── EmailService.java                  # 이메일 발송 서비스
│   └── EmailValidator.java                # 이메일 검증
└── dto/
    └── EmailSendResult.java               # 발송 결과 DTO

단계별 구현

1단계: Entity 및 Repository

Email.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
package com.example.batch.entity;

import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "email_send_queue")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Email {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "to_email", nullable = false)
    private String toEmail;

    @Column(nullable = false)
    private String subject;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String content;

    @Column(nullable = false, length = 20)
    @Enumerated(EnumType.STRING)
    private EmailStatus status = EmailStatus.PENDING;

    @Column(name = "sent_at")
    private LocalDateTime sentAt;

    @Column(name = "error_message", columnDefinition = "TEXT")
    private String errorMessage;

    @Column(name = "retry_count")
    private Integer retryCount = 0;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at")
    private LocalDateTime updatedAt;

    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
        updatedAt = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        updatedAt = LocalDateTime.now();
    }

    /**
     * 발송 성공 처리
     */
    public void markAsSent() {
        this.status = EmailStatus.SUCCESS;
        this.sentAt = LocalDateTime.now();
        this.errorMessage = null;
    }

    /**
     * 발송 실패 처리
     */
    public void markAsFailed(String errorMessage) {
        this.status = EmailStatus.FAILED;
        this.errorMessage = errorMessage;
        this.retryCount++;
    }

    /**
     * 재시도 가능 여부
     */
    public boolean canRetry() {
        return this.retryCount < 3;
    }
}

EmailStatus.java

1
2
3
4
5
6
7
package com.example.batch.entity;

public enum EmailStatus {
    PENDING,   // 대기 중
    SUCCESS,   // 발송 성공
    FAILED     // 발송 실패
}

EmailRepository.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.example.batch.repository;

import com.example.batch.entity.Email;
import com.example.batch.entity.EmailStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.time.LocalDateTime;
import java.util.List;

public interface EmailRepository extends JpaRepository<Email, Long> {

    /**
     * PENDING 상태의 이메일 조회
     */
    List<Email> findByStatusOrderByIdAsc(EmailStatus status);

    /**
     * 특정 기간 동안의 발송 통계
     */
    @Query("""
        SELECT e.status, COUNT(e)
        FROM Email e
        WHERE e.sentAt BETWEEN :startDate AND :endDate
        GROUP BY e.status
    """)
    List<Object[]> getStatistics(LocalDateTime startDate, LocalDateTime endDate);
}

2단계: 이메일 발송 서비스

EmailService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
package com.example.batch.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

/**
 * 이메일 발송 서비스 (시뮬레이션)
 */
@Slf4j
@Service
public class EmailService {

    private static final double SUCCESS_RATE = 0.9; // 90% 성공률

    /**
     * 이메일 발송
     *
     * @return true: 성공, false: 실패
     * @throws IllegalArgumentException 잘못된 이메일 주소
     * @throws EmailSendException 발송 실패
     */
    public boolean send(String toEmail, String subject, String content) {
        log.debug("Sending email to: {}, subject: {}", toEmail, subject);

        // 1. 이메일 주소 검증
        validateEmail(toEmail);

        // 2. 네트워크 지연 시뮬레이션 (10~50ms)
        simulateNetworkDelay();

        // 3. 랜덤 실패 시뮬레이션 (10% 확률)
        if (Math.random() > SUCCESS_RATE) {
            log.warn("Failed to send email to: {} (simulated failure)", toEmail);
            throw new EmailSendException("SMTP connection timeout");
        }

        log.debug("Email sent successfully to: {}", toEmail);
        return true;
    }

    /**
     * 이메일 주소 검증
     */
    private void validateEmail(String email) {
        if (email == null || email.isBlank()) {
            throw new IllegalArgumentException("Email address cannot be empty");
        }

        // "invalid"가 포함된 이메일은 무조건 실패
        if (email.contains("invalid")) {
            throw new IllegalArgumentException("Invalid email address: " + email);
        }

        // 기본 형식 검증
        if (!email.matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")) {
            throw new IllegalArgumentException("Invalid email format: " + email);
        }
    }

    /**
     * 네트워크 지연 시뮬레이션
     */
    private void simulateNetworkDelay() {
        try {
            long delay = 10 + (long) (Math.random() * 40); // 10~50ms
            TimeUnit.MILLISECONDS.sleep(delay);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new EmailSendException("Email sending interrupted", e);
        }
    }
}

EmailSendException.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example.batch.service;

/**
 * 이메일 발송 실패 예외
 */
public class EmailSendException extends RuntimeException {

    public EmailSendException(String message) {
        super(message);
    }

    public EmailSendException(String message, Throwable cause) {
        super(message, cause);
    }
}

3단계: Reader 구현

ItemReader 방식 비교

Spring Batch는 여러 종류의 Reader를 제공합니다.

Reader특징사용 시기
JpaPagingItemReaderJPA 페이징 조회복잡한 조건, 엔티티 그래프
JdbcPagingItemReaderJDBC 페이징 조회단순 조회, 성능 중요
JdbcCursorItemReaderJDBC 커서 기반대용량, 스트리밍
RepositoryItemReaderSpring Data Repository기존 Repository 재사용

이 예제에서는 JpaPagingItemReader를 사용하겠습니다.


EmailItemReader 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package com.example.batch.job;

import com.example.batch.entity.Email;
import com.example.batch.entity.EmailStatus;
import jakarta.persistence.EntityManagerFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.item.database.JpaPagingItemReader;
import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Map;

@Slf4j
@Configuration
@RequiredArgsConstructor
public class EmailItemReaderConfig {

    private final EntityManagerFactory entityManagerFactory;

    /**
     * 이메일 Reader
     *
     * pageSize: 한 번에 읽어올 데이터 개수
     */
    @Bean
    public JpaPagingItemReader<Email> emailItemReader() {
        return new JpaPagingItemReaderBuilder<Email>()
                .name("emailItemReader")
                .entityManagerFactory(entityManagerFactory)
                .queryString("SELECT e FROM Email e WHERE e.status = :status ORDER BY e.id ASC")
                .parameterValues(Map.of("status", EmailStatus.PENDING))
                .pageSize(1000)  // fetch-size
                .build();
    }
}

Reader 동작 원리

sequenceDiagram
    participant Reader
    participant DB

    Note over Reader,DB: 1차 조회 (Page 0)
    Reader->>DB: SELECT ... LIMIT 1000 OFFSET 0
    DB-->>Reader: [Email 1~1000]

    loop 1000번 호출
        Reader->>Reader: read() → 1건씩 반환
    end

    Note over Reader,DB: 2차 조회 (Page 1)
    Reader->>DB: SELECT ... LIMIT 1000 OFFSET 1000
    DB-->>Reader: [Email 1001~2000]

핵심 포인트를 정리하면 다음과 같습니다.

  • pageSize만큼 한 번에 조회 (네트워크 왕복 최소화)
  • read() 메서드는 1건씩 반환 (메모리 효율적)
  • ORDER BY 필수 (일관된 순서 보장)

4단계: Processor 구현

EmailItemProcessor.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package com.example.batch.job;

import com.example.batch.entity.Email;
import com.example.batch.service.EmailSendException;
import com.example.batch.service.EmailService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.stereotype.Component;

/**
 * 이메일 발송 Processor
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class EmailItemProcessor implements ItemProcessor<Email, Email> {

    private final EmailService emailService;

    @Override
    public Email process(Email email) throws Exception {
        log.debug("Processing email: id={}, to={}", email.getId(), email.getToEmail());

        try {
            // 1. 이메일 발송 시도
            emailService.send(
                email.getToEmail(),
                email.getSubject(),
                email.getContent()
            );

            // 2. 성공 처리
            email.markAsSent();
            log.info("Email sent successfully: id={}, to={}",
                email.getId(), email.getToEmail());

            return email;

        } catch (IllegalArgumentException e) {
            // 3. 검증 실패 (Skip 대상)
            log.warn("Invalid email address: id={}, to={}, error={}",
                email.getId(), email.getToEmail(), e.getMessage());

            email.markAsFailed("Invalid email: " + e.getMessage());

            // null 반환 = Writer에 전달하지 않음 = Skip
            return null;

        } catch (EmailSendException e) {
            // 4. 발송 실패 (Retry 또는 Skip)
            log.error("Failed to send email: id={}, to={}, error={}",
                email.getId(), email.getToEmail(), e.getMessage());

            email.markAsFailed(e.getMessage());

            // 예외 던지기 = Skip 정책에 따라 처리
            throw e;
        }
    }
}

Processor 처리 흐름도

flowchart TD
    Input["Email 객체 입력"]
    Send["emailService.send()"]
    Success{"성공?"}
    MarkSent["email.markAsSent()"]
    ReturnEmail["return email → Writer로 전달"]
    Invalid{"검증 실패?<br/>(IllegalArgumentException)"}
    MarkFailed1["email.markAsFailed()"]
    ReturnNull["return null → Skip"]
    SendFail{"발송 실패?<br/>(EmailSendException)"}
    MarkFailed2["email.markAsFailed()"]
    ThrowEx["throw e → Skip 정책 적용"]

    Input --> Send
    Send --> Success
    Success -->|Yes| MarkSent --> ReturnEmail
    Success -->|No| Invalid
    Invalid -->|Yes| MarkFailed1 --> ReturnNull
    Invalid -->|No| SendFail
    SendFail -->|Yes| MarkFailed2 --> ThrowEx

핵심 개념을 정리해보겠습니다.

  • return email: 정상 처리 → Writer로 전달
  • return null: Skip → Writer로 전달 안 함
  • throw Exception: Skip 또는 Retry 정책 적용

5단계: Writer 구현

EmailItemWriter.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.example.batch.job;

import com.example.batch.entity.Email;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.item.Chunk;
import org.springframework.batch.item.ItemWriter;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import jakarta.persistence.EntityManager;

/**
 * 이메일 발송 결과 저장 Writer
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class EmailItemWriter implements ItemWriter<Email> {

    private final EntityManager entityManager;

    @Override
    @Transactional
    public void write(Chunk<? extends Email> chunk) throws Exception {
        long startTime = System.currentTimeMillis();

        for (Email email : chunk.getItems()) {
            // JPA merge로 업데이트
            entityManager.merge(email);
        }

        // Batch flush
        entityManager.flush();
        entityManager.clear();

        long elapsed = System.currentTimeMillis() - startTime;
        log.info("Updated {} emails in {}ms", chunk.size(), elapsed);
    }
}

Writer 동작 원리

sequenceDiagram
    participant Writer
    participant EntityManager
    participant DB

    Note over Writer: Chunk<Email> items (1000건)

    loop 1000번
        Writer->>EntityManager: merge(email)
        Note over EntityManager: UPDATE SQL 생성 (실행 X)
    end

    Writer->>EntityManager: flush()
    EntityManager->>DB: Hibernate Batch Update<br/>UPDATE ... WHERE id IN (?, ?, ...)
    Note over DB: 1000건 한 번에 실행

    Writer->>EntityManager: clear()
    Note over EntityManager: 영속성 컨텍스트 초기화

    Note over Writer: COMMIT

성능 최적화 포인트입니다.

  • entityManager.flush(): Batch Update 강제 실행
  • entityManager.clear(): 메모리 해제
  • Hibernate의 batch_size 설정 활용

6단계: Job Configuration

EmailSendJobConfiguration.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package com.example.batch.job;

import com.example.batch.entity.Email;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemWriter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;

@Slf4j
@Configuration
@RequiredArgsConstructor
public class EmailSendJobConfiguration {

    private final JobRepository jobRepository;
    private final PlatformTransactionManager transactionManager;

    private static final int CHUNK_SIZE = 1000;
    private static final int SKIP_LIMIT = 100;

    /**
     * Job 정의
     */
    @Bean
    public Job emailSendJob(Step emailSendStep) {
        return new JobBuilder("emailSendJob", jobRepository)
                .start(emailSendStep)
                .build();
    }

    /**
     * Step 정의
     */
    @Bean
    public Step emailSendStep(
            ItemReader<Email> emailItemReader,
            ItemProcessor<Email, Email> emailItemProcessor,
            ItemWriter<Email> emailItemWriter
    ) {
        return new StepBuilder("emailSendStep", jobRepository)
                .<Email, Email>chunk(CHUNK_SIZE, transactionManager)
                .reader(emailItemReader)
                .processor(emailItemProcessor)
                .writer(emailItemWriter)
                .faultTolerant()  // 에러 허용 활성화
                .skip(Exception.class)  // 모든 예외 Skip 대상
                .skipLimit(SKIP_LIMIT)  // 최대 100건까지 Skip 허용
                .build();
    }
}

에러 처리 전략

Skip vs Retry

Skip: 건너뛰기

1
2
3
.faultTolerant()
.skip(Exception.class)
.skipLimit(100)

동작 방식을 살펴보겠습니다.

1
2
3
4
5
6
7
8
Chunk 1000건 처리 중...
├─ Item 1: 성공
├─ Item 2: 성공
├─ Item 3: 예외 발생 → Skip (1/100)
├─ Item 4: 성공
├─ Item 5: 예외 발생 → Skip (2/100)
...
└─ Commit (998건 성공, 2건 Skip)

사용 시기는 일부 실패해도 전체 처리는 계속되어야 할 때입니다.

  • 잘못된 이메일 주소
  • 일시적인 네트워크 오류
  • 데이터 검증 실패

Retry: 재시도

1
2
3
.faultTolerant()
.retry(EmailSendException.class)
.retryLimit(3)

동작 방식입니다.

1
2
3
4
5
6
7
8
9
10
11
12
Item 처리 시도...
├─ 1차 시도: 실패 (네트워크 오류)
├─ 2차 시도: 실패
├─ 3차 시도: 성공
└─ 결과: 성공

또는

├─ 1차 시도: 실패
├─ 2차 시도: 실패
├─ 3차 시도: 실패
└─ Skip 처리

사용 시기는 재시도하면 성공할 가능성이 있을 때입니다.

  • 일시적인 네트워크 장애
  • DB 락 타임아웃
  • 외부 API 응답 지연

Skip과 Retry 조합

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Bean
public Step emailSendStep(...) {
    return new StepBuilder("emailSendStep", jobRepository)
            .<Email, Email>chunk(CHUNK_SIZE, transactionManager)
            .reader(emailItemReader)
            .processor(emailItemProcessor)
            .writer(emailItemWriter)
            .faultTolerant()
            // Retry 설정
            .retry(EmailSendException.class)  // 발송 실패는 재시도
            .retryLimit(3)  // 최대 3번
            // Skip 설정
            .skip(EmailSendException.class)  // 3번 실패하면 Skip
            .skip(IllegalArgumentException.class)  // 검증 실패는 바로 Skip
            .skipLimit(100)  // 전체 100건까지 허용
            .build();
}

처리 흐름

flowchart TD
    Exception["예외 발생"]
    IsIllegal{"IllegalArgumentException?<br/>(검증 실패)"}
    SkipNow["Skip 처리<br/>(재시도 안 함)"]
    IsEmail{"EmailSendException?<br/>(발송 실패)"}
    CanRetry{"재시도 횟수 < 3?"}
    Retry["Retry"]
    Skip["Skip 처리"]

    Exception --> IsIllegal
    IsIllegal -->|Yes| SkipNow
    IsIllegal -->|No| IsEmail
    IsEmail -->|Yes| CanRetry
    CanRetry -->|Yes| Retry
    CanRetry -->|No| Skip

트랜잭션 관리

Chunk 기반 트랜잭션

sequenceDiagram
    participant Chunk1 as Chunk 1 (1000건)
    participant Chunk2 as Chunk 2 (1000건)
    participant DB

    Note over Chunk1,DB: Transaction 1
    Chunk1->>Chunk1: BEGIN TRANSACTION
    Chunk1->>Chunk1: Reader: 1000건 읽기
    Chunk1->>Chunk1: Processor: 1000건 처리 (900 성공, 100 Skip)
    Chunk1->>DB: Writer: 900건 DB 업데이트
    Chunk1->>Chunk1: COMMIT

    Note over Chunk2,DB: Transaction 2
    Chunk2->>Chunk2: BEGIN TRANSACTION
    Chunk2->>Chunk2: ...
    Chunk2->>Chunk2: COMMIT

핵심 포인트입니다.

  • Chunk = Transaction 단위
  • Chunk 성공 시 전체 COMMIT
  • Chunk 실패 시 전체 ROLLBACK (Skip 제외)
  • 다음 Chunk는 새 Transaction

application.yml 트랜잭션 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
spring:
  jpa:
    properties:
      hibernate:
        # Batch Update 최적화
        jdbc:
          batch_size: 100  # 한 번에 실행할 SQL 개수
        order_inserts: true
        order_updates: true

        # 로깅
        show_sql: true
        format_sql: true

# 배치 설정
batch:
  chunk-size: 1000  # Chunk 크기
  page-size: 1000   # Reader 페이지 크기
  skip-limit: 100   # Skip 제한

모니터링 및 로깅

Job Execution Listener

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package com.example.batch.job.listener;

import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobExecutionListener;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.time.LocalDateTime;

/**
 * Job 실행 리스너
 */
@Slf4j
@Component
public class EmailJobExecutionListener implements JobExecutionListener {

    @Override
    public void beforeJob(JobExecution jobExecution) {
        log.info("========================================");
        log.info("Email Send Job Started");
        log.info("Job Name: {}", jobExecution.getJobInstance().getJobName());
        log.info("Start Time: {}", jobExecution.getStartTime());
        log.info("========================================");
    }

    @Override
    public void afterJob(JobExecution jobExecution) {
        LocalDateTime startTime = jobExecution.getStartTime();
        LocalDateTime endTime = jobExecution.getEndTime();
        Duration duration = Duration.between(startTime, endTime);

        log.info("========================================");
        log.info("Email Send Job Finished");
        log.info("Status: {}", jobExecution.getStatus());
        log.info("Exit Status: {}", jobExecution.getExitStatus());
        log.info("Duration: {} seconds", duration.getSeconds());

        jobExecution.getStepExecutions().forEach(stepExecution -> {
            log.info("Step: {}", stepExecution.getStepName());
            log.info("  - Read Count: {}", stepExecution.getReadCount());
            log.info("  - Write Count: {}", stepExecution.getWriteCount());
            log.info("  - Skip Count: {}", stepExecution.getSkipCount());
            log.info("  - Commit Count: {}", stepExecution.getCommitCount());
            log.info("  - Rollback Count: {}", stepExecution.getRollbackCount());
        });
        log.info("========================================");
    }
}

Job에 Listener 등록

1
2
3
4
5
6
7
8
9
10
@Bean
public Job emailSendJob(
        Step emailSendStep,
        EmailJobExecutionListener listener
) {
    return new JobBuilder("emailSendJob", jobRepository)
            .listener(listener)  // Listener 등록
            .start(emailSendStep)
            .build();
}

실행 로그 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2026-01-22 16:30:00.123  INFO --- ========================================
2026-01-22 16:30:00.124  INFO --- Email Send Job Started
2026-01-22 16:30:00.125  INFO --- Job Name: emailSendJob
2026-01-22 16:30:00.126  INFO --- Start Time: 2026-01-22T16:30:00
2026-01-22 16:30:00.127  INFO --- ========================================

2026-01-22 16:30:01.234  INFO --- Processing email: id=1, to=user1@example.com
2026-01-22 16:30:01.235  INFO --- Email sent successfully: id=1, to=user1@example.com
2026-01-22 16:30:01.345  INFO --- Processing email: id=2, to=user2@example.com
2026-01-22 16:30:01.346  INFO --- Email sent successfully: id=2, to=user2@example.com
2026-01-22 16:30:01.456  WARN --- Invalid email address: id=4, to=invalid@test.com
...
2026-01-22 16:30:05.567  INFO --- Updated 900 emails in 234ms

2026-01-22 16:30:10.678  INFO --- ========================================
2026-01-22 16:30:10.679  INFO --- Email Send Job Finished
2026-01-22 16:30:10.680  INFO --- Status: COMPLETED
2026-01-22 16:30:10.681  INFO --- Exit Status: COMPLETED
2026-01-22 16:30:10.682  INFO --- Duration: 10 seconds
2026-01-22 16:30:10.683  INFO --- Step: emailSendStep
2026-01-22 16:30:10.684  INFO ---   - Read Count: 1000
2026-01-22 16:30:10.685  INFO ---   - Write Count: 900
2026-01-22 16:30:10.686  INFO ---   - Skip Count: 100
2026-01-22 16:30:10.687  INFO ---   - Commit Count: 1
2026-01-22 16:30:10.688  INFO ---   - Rollback Count: 0
2026-01-22 16:30:10.689  INFO --- ========================================

테스트

통합 테스트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
package com.example.batch.job;

import com.example.batch.entity.Email;
import com.example.batch.entity.EmailStatus;
import com.example.batch.repository.EmailRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.test.JobLauncherTestUtils;
import org.springframework.batch.test.context.SpringBatchTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@SpringBatchTest
@ActiveProfiles("test")
class EmailSendJobTest {

    @Autowired
    private JobLauncherTestUtils jobLauncherTestUtils;

    @Autowired
    private EmailRepository emailRepository;

    @Autowired
    private Job emailSendJob;

    @BeforeEach
    void setUp() {
        emailRepository.deleteAll();
        jobLauncherTestUtils.setJob(emailSendJob);
    }

    @Test
    void 이메일_발송_배치_성공_테스트() throws Exception {
        // Given: 테스트 데이터 생성
        for (int i = 1; i <= 100; i++) {
            Email email = Email.builder()
                    .toEmail("user" + i + "@example.com")
                    .subject("Test Subject " + i)
                    .content("Test Content " + i)
                    .status(EmailStatus.PENDING)
                    .build();
            emailRepository.save(email);
        }

        // When: 배치 실행
        JobParameters jobParameters = new JobParametersBuilder()
                .addLong("timestamp", System.currentTimeMillis())
                .toJobParameters();

        JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters);

        // Then: 결과 검증
        assertThat(jobExecution.getStatus().isUnsuccessful()).isFalse();

        // 대부분 성공 (90% 성공률)
        long successCount = emailRepository.findByStatusOrderByIdAsc(EmailStatus.SUCCESS).size();
        assertThat(successCount).isGreaterThan(80);
    }

    @Test
    void 잘못된_이메일_주소_Skip_테스트() throws Exception {
        // Given: 잘못된 이메일 포함
        Email validEmail = Email.builder()
                .toEmail("valid@example.com")
                .subject("Valid")
                .content("Valid")
                .status(EmailStatus.PENDING)
                .build();

        Email invalidEmail = Email.builder()
                .toEmail("invalid@test.com")
                .subject("Invalid")
                .content("Invalid")
                .status(EmailStatus.PENDING)
                .build();

        emailRepository.save(validEmail);
        emailRepository.save(invalidEmail);

        // When: 배치 실행
        JobParameters jobParameters = new JobParametersBuilder()
                .addLong("timestamp", System.currentTimeMillis())
                .toJobParameters();

        JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters);

        // Then: Job은 성공, invalid 이메일은 Skip
        assertThat(jobExecution.getStatus().isUnsuccessful()).isFalse();

        Email failed = emailRepository.findById(invalidEmail.getId()).orElseThrow();
        assertThat(failed.getStatus()).isEqualTo(EmailStatus.FAILED);
        assertThat(failed.getErrorMessage()).contains("Invalid email");
    }
}

핵심 요약

구현 포인트

컴포넌트역할핵심 포인트
ReaderDB에서 데이터 조회pageSize로 한 번에 조회, 1건씩 반환
Processor비즈니스 로직 수행null 반환 = Skip, 예외 = Skip/Retry
WriterDB에 결과 저장Chunk 단위로 Batch Update

에러 처리

전략사용 시기설정
Skip일부 실패해도 계속 진행.skip(Exception.class).skipLimit(100)
Retry재시도하면 성공 가능.retry(Exception.class).retryLimit(3)

트랜잭션

  • Chunk = Transaction 단위
  • Chunk 성공 → COMMIT
  • Chunk 실패 → ROLLBACK (Skip 제외)

다음 편 예고

3편에서는 성능 최적화와 운영 노하우를 다루겠습니다.

다룰 내용은 다음과 같습니다.

  • 멀티스레드 처리 (Parallel Step)
  • Partitioning 전략
  • Chunk 크기 최적화
  • 스케줄링 (Quartz, Spring Scheduler)
  • 운영 환경 배포
  • 모니터링 및 알림

2편에서 다룬 Skip/Retry 전략은 실무에서 가장 많이 사용하는 에러 처리 방식입니다. 꼭 직접 구현해보시길 권장합니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.