🎯 하루 매출을 계산하는 Job
구현해야 할 정산 로직 Job 요구 사항을 먼저 살펴보겠습니다.
- 매출 집계: 가게별 총 매출, 환불 금액, 순매출을 계산합니다.
- 수수료 계산: 각 가게에 적용되는 수수료(예: 결제 수수료, 플랫폼 수수료 등)를 계산하여 최종 정산 금액을 계산합니다.
- 할인 처리: 특정 프로모션이나 할인 이벤트가 적용된 경우, 이를 반영하여 정산 금액을 계산합니다.
이전 포스팅에서 구현한 NormalizedTransaction 테이블을 조회해 수수료 계산, 할인 이벤트 등을 적용한뒤, 각 가게별 총 매출, 환불 금액, 순 매출 을 집계해야 합니다.
💡 통계 관련 로직은 어떻게 구현하지 ?
[ 통계 관련 로직 구현을 위한 아이디어 ]
reader 와 processor 는 1건씩 조회해 처리 합니다. 그런데 가게별 통계 를 계산하기 위해선 가게에 있는 모든 거래 내역 (OrderTransaction) 을 조회해 매출을 합산해야 하는데, 이를 거래 내역 하나씩 조회하면 그만큼의 쿼리를 DB 에 쏴줘야 합니다.
이를 방지하기 위해서 DB 에 조회 쿼리를 보낼때 Group BY 를 활용해 가게를 기준으로 묶어줄 예정입니다. 이후 거래 내역을 합산할 DTO 에 DB 에서 조회한 합산 결과를 임시로 저장합니다. 이후 processor 과정에서 Dto 를 Entity 로 변환하고 chunk 만큼 쌓이면 이를 DB 에 저장할 예정입니다.
🎯 매일 실행되는 정산 시스템 Job
✅ SettlementAggregation
package com.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@AllArgsConstructor
public class SettlementAggregation {
private UUID shopId;
private String shopName;
private double totalRefunds;
private double totalSales;
private double netSales;
private LocalDateTime settlementDateTime;
}
주문 내역(OrderTransaction) 을 DB 에서 조회한 뒤 정산 정보를 담을 DTO 입니다. 바로 Settlement(Entity) 로 조회하는 방법도 있지만, JPQL 에서 new 생성자를 이용해 엔티티를 직접 생성하면 영속성 컨텍스트에서 관리되지 않습니다. 때문에 정산 정보를 합산하는 과정에서 장애가 발생할 수 있기 때문에 이러한 DTO 를 만듭니다.
(Native Query 를 이용해 Entity 를 바로 조회할 수 있으나 데이터베이스가 변경될 경우 유지보수가 어려울 수 있다 생각했습니다.)
✅ Settlement
package com.domain.settlement.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.UuidGenerator;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* 일별 정산 내역을 나타낼 entity
*/
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "settlement")
@EntityListeners(AuditingEntityListener.class)
@Getter
@Builder
@AllArgsConstructor
public class Settlement {
@Id
@UuidGenerator
@Column(name = "settlement_id", nullable = false, updatable = false)
private UUID id;
@Column(name = "shop_id", nullable = false)
private UUID shopId;
@Column(name = "shop_name", nullable = false)
private String shopName;
@Column(name = "settlement_date_time", nullable = false)
private LocalDateTime settlementDateTime; // 정산 날짜
@Column(name = "total_sales", nullable = true)
private double totalSales; // 총 매출
@Column(name = "total_refunds", nullable = true)
private double totalRefunds; // 총 환불 금액
@Column(name = "net_sales", nullable = true)
private double netSales; // 순 매출 (수수료 및 할인이 반영된 금액)
}
통계 결과를 나타낼 클래스 입니다. updateSettlement 메서드는 통계 배치 프로세스에서 사용할 매출 계산을 위해 만들었습니다. 새로운 Settlement 객체를 파라미터로 받아와 현재 매출에 더해 통계 결과를 갱신합니다.
✅ SettlementCalculationComponents
package com.etl;
import com.domain.settlement.entity.Settlement;
import com.dto.SettlementAggregation;
import com.parameters.DateParameter;
import jakarta.persistence.EntityManagerFactory;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.database.JpaItemWriter;
import org.springframework.batch.item.database.JpaPagingItemReader;
import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder;
import java.util.Map;
public class SettlementCalculationComponents {
public static JpaPagingItemReader<SettlementAggregation> settlementReader(EntityManagerFactory entityManagerFactory, DateParameter dateParameter) {
String query = """
SELECT new com.dto.SettlementAggregation(
t.shopId,
t.shopName,
SUM(CASE WHEN t.status = 'CANCEL' THEN t.price ELSE 0 END),
SUM(CASE WHEN t.status <> 'CANCEL' THEN t.price ELSE 0 END),
SUM(CASE WHEN t.status <> 'CANCEL' THEN
CASE
WHEN t.discountType = 'VIP_DISCOUNT' THEN (t.price * 0.9 - 1000)
WHEN t.discountType = 'FIRST_ORDER_DISCOUNT' THEN (t.price * 0.95 - 1000)
ELSE (t.price - 1000)
END
ELSE 0 END),
MAX(t.completionDateTime)
)
FROM OrderTransaction t
WHERE FUNCTION('DATE', t.completionDateTime) = :requestDate
GROUP BY t.shopId, t.shopName
""";
return new JpaPagingItemReaderBuilder<SettlementAggregation>()
.name("settlementReader")
.entityManagerFactory(entityManagerFactory)
.pageSize(100)
.queryString(query)
.parameterValues(Map.of("requestDate", dateParameter.getRequestDate()))
.build();
}
public static ItemProcessor<SettlementAggregation, Settlement> settlementItemProcessor() {
return aggregation -> {
return Settlement.builder()
.shopId(aggregation.getShopId())
.shopName(aggregation.getShopName())
.totalSales(aggregation.getTotalSales())
.totalRefunds(aggregation.getTotalRefunds())
.netSales(aggregation.getNetSales())
.settlementDateTime(aggregation.getSettlementDateTime())
.build();
};
}
public static JpaItemWriter<Settlement> settlementJpaItemWriter(EntityManagerFactory entityManagerFactory) {
JpaItemWriter<Settlement> writer = new JpaItemWriter<>();
writer.setEntityManagerFactory(entityManagerFactory);
return writer;
}
}
주문 정보들을 통합해 정산 결과를 계산하는 ItemReader, ItemProcessor, ItemWriter 를 정의했습니다.
reader
public static JpaPagingItemReader<SettlementAggregation> settlementReader(EntityManagerFactory entityManagerFactory, DateParameter dateParameter) {
String query = """
SELECT new com.dto.SettlementAggregation(
t.shopId,
t.shopName,
SUM(CASE WHEN t.status = 'CANCEL' THEN t.price ELSE 0 END),
SUM(CASE WHEN t.status <> 'CANCEL' THEN t.price ELSE 0 END),
SUM(CASE WHEN t.status <> 'CANCEL' THEN
CASE
WHEN t.discountType = 'VIP_DISCOUNT' THEN (t.price * 0.9 - 1000)
WHEN t.discountType = 'FIRST_ORDER_DISCOUNT' THEN (t.price * 0.95 - 1000)
ELSE (t.price - 1000)
END
ELSE 0 END),
MAX(t.completionDateTime)
)
FROM OrderTransaction t
WHERE FUNCTION('DATE', t.completionDateTime) = :requestDate
GROUP BY t.shopId, t.shopName
""";
return new JpaPagingItemReaderBuilder<SettlementAggregation>()
.name("settlementReader")
.entityManagerFactory(entityManagerFactory)
.pageSize(100)
.queryString(query)
.parameterValues(Map.of("requestDate", dateParameter.getRequestDate()))
.build();
}
OrderTransaction 테이블에서 shopId 를 그룹화해 SettlementAggreagation 을 만들어 조회합니다. 주문상태가 'CANCEL' 이면 총 환불 금액에 값을 더하고, 아닌 경우 총 주문금액에 값을 더합니다.
순매출은 기존 주문 금액에서 거래 수수료(1000원 할인) 와 주문에 있는 DiscountType 을 기준으로 결제된 실제 거래 금액입니다. 이를 쿼리에서 수수료를 계산하는데, OrderTransaction 을 DB 에서 그룹화해 조회하므로 수수료 계산 로직을 쿼리에 직접 넣어줬습니다.
하지만 이러한 방식은 할인 정책이 바뀔 경우 유지 보수가 어렵습니다. 그러나 Spring Batch 는 대량의 데이터를 효율적으로 처리하는 것이 궁극적인 목표라 생각해 유지보수 < 성능 이 더 중요하다 판단했기에 쿼리가 좀 복잡해지더라도 정산 정보를 계산을 쿼리에 포함시켰습니다.
가장 최근 거래 시간을 계산하기 위해 JobParameter 로 넘어온 날짜를 기준으로 MAX() 를 지정해줍니다. 이렇게 DB 에서 그룹화해 계산하는 방식은 주문 내역만큼 Read 해야 하는 쿼리 수를 줄여줘 효율적인 조회가 가능합니다.
processor
public static ItemProcessor<SettlementAggregation, Settlement> settlementItemProcessor() {
return aggregation -> {
return Settlement.builder()
.shopId(aggregation.getShopId())
.shopName(aggregation.getShopName())
.totalSales(aggregation.getTotalSales())
.totalRefunds(aggregation.getTotalRefunds())
.netSales(aggregation.getTotalSales() - aggregation.getTotalRefunds()) // 실제 정산 금액 계산
.settlementDateTime(aggregation.getSettlementDateTime())
.build();
};
}
read 에서 조회된 정산정보 Dto 를 Entity 로 변환하는 과정입니다.
writer
@Bean
public JpaItemWriter<Settlement> settlementJpaItemWriter() {
JpaItemWriter<Settlement> writer = new JpaItemWriter<>();
writer.setEntityManagerFactory(entityManagerFactory);
return writer;
}
Chunk 사이즈만큼 통계 정보 (Settlement) 가 쌓이면 DB 에 저장됩니다.
✅ SettlementCalculateJobConfig
package com.job;
import com.domain.settlement.entity.Settlement;
import com.dto.SettlementAggregation;
import com.etl.SettlementCalculationComponents;
import com.parameters.DateParameter;
import jakarta.persistence.EntityManagerFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.*;
import org.springframework.batch.core.configuration.annotation.JobScope;
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.database.JpaItemWriter;
import org.springframework.batch.item.database.JpaPagingItemReader;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
/**
* 일별 정산 시스템 (하루에 한번 실행)
*/
@Slf4j
@RequiredArgsConstructor
@Configuration
public class SettlementCalculationJobConfig {
private static final String JOB_NAME = "settlementJob";
private static final String STEP_NAME = "settlementStep";
private final JobRepository jobRepository;
private final PlatformTransactionManager transactionManager;
private final EntityManagerFactory entityManagerFactory;
private final DateParameter jobParameter;
@Bean(JOB_NAME + "jobParameter")
@JobScope
public DateParameter settlementCalculationParameter() {
return new DateParameter();
}
@Bean
public Job settlementJob() {
return new JobBuilder(JOB_NAME, jobRepository)
.start(settlementStep())
.build();
}
@Bean
@JobScope
public Step settlementStep() {
JpaPagingItemReader<SettlementAggregation> reader = SettlementCalculationComponents.settlementReader(entityManagerFactory, jobParameter);
ItemProcessor<SettlementAggregation, Settlement> processor = SettlementCalculationComponents.settlementItemProcessor();
JpaItemWriter<Settlement> writer = SettlementCalculationComponents.settlementJpaItemWriter(entityManagerFactory);
return new StepBuilder(STEP_NAME, jobRepository)
.<SettlementAggregation, Settlement>chunk(100, transactionManager)
.reader(reader)
.processor(processor)
.writer(writer)
.build();
}
}
하루의 정산 정보를 계산하는 Job 단위 입니다.
🎯 테스트 코드
SettlementCalculationJobConfig 를 테스트하는 코드를 만들건데, 두가지 시나리오에 대한 테스트 코드를 만들려 합니다.
(1) 통계 기능이 잘 저장되는지 확인하는 테스트
(2) 할인 기능이 잘 적용됐는지 확인하는 테스트
✅ OrderTrnasacitonHelper
package com.generator;
import com.domain.order.constants.DiscountType;
import com.domain.order.entity.OrderTransaction;
import com.domain.order.constants.OrderStatus;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class OrderTransactionHelper {
public static List<OrderTransaction> createOrderTransactions(UUID shopId, String shopName, LocalDateTime completionDateTime){
List<OrderTransaction> response = new ArrayList<>();
for(int i=0; i<10; i++) {
response.add(OrderTransaction.builder()
.shopName(shopName)
.shopId(shopId)
.price(10000)
.discountType(DiscountType.VIP_DISCOUNT)
.completionDateTime(completionDateTime)
.status(OrderStatus.COMPLEMENT)
.build());
}
return response;
}
}
거래 정보 생성을 도와주는 클래스 입니다.
✅ SettlementCalculationJobTest
package com.job;
import com.config.QueryDslConfig;
import com.domain.order.entity.OrderTransaction;
import com.domain.settlement.entity.Settlement;
import com.domain.settlement.repository.NormalizedTransactionRepository;
import com.domain.settlement.repository.SettlementRepository;
import com.domain.shop.repository.ShopRepository;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.batch.core.BatchStatus;
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.JobRepositoryTestUtils;
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 org.springframework.test.context.junit.jupiter.SpringExtension;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import static com.generator.OrderTransactionHelper.createOrderTransactions;
@SpringBatchTest
@ActiveProfiles("test")
@ExtendWith(SpringExtension.class)
@SpringBootTest(classes={SettlementCalculationJobConfig.class, TestBatchLegacyConfig.class, QueryDslConfig.class})
public class SettlementCalculationJobTest {
@Autowired
private JobLauncherTestUtils jobLauncherTestUtils;
@Autowired
private ShopRepository shopRepository;
@Autowired
private NormalizedTransactionRepository normalizedTransactionRepository;
@Autowired
private SettlementRepository settlementRepository;
@Autowired
private JobRepositoryTestUtils jobRepositoryTestUtils;
@BeforeEach
public void tearDown() {
jobRepositoryTestUtils.removeJobExecutions();
}
@Test
void 가게별_통계_기능_통합_테스트() throws Exception {
//given
LocalDateTime localDateTime = LocalDateTime.of(2024,10,23,13,13);
for(int i=0; i<10; i++){
List<OrderTransaction> normalizedTransactions = createOrderTransactions(UUID.randomUUID(), "SHOPNAME" + i, localDateTime);
normalizedTransactions.stream()
.forEach(normalizedTransaction -> {
normalizedTransactionRepository.save(normalizedTransaction);
});
}
JobParameters jobParameters = new JobParametersBuilder()
.addString("requestDate", "2024-10-23T14:30:45.123")
.toJobParameters();
//when
JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters);
//then
List<Settlement> all = settlementRepository.findAll();
Assertions.assertThat(all.size()).isEqualTo(10);
Assertions.assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
}
@Test
void 할인_적용_테스트() throws Exception {
//VIP_DISCOUNT == 10퍼센트 할인 && 거래 수수료 1000원
//given
LocalDateTime localDateTime = LocalDateTime.of(2024,10,23,13,13);
List<OrderTransaction> normalizedTransactions = createOrderTransactions(UUID.randomUUID(), "SHOPNAME", localDateTime);
normalizedTransactions.stream()
.forEach(normalizedTransaction -> normalizedTransactionRepository.save(normalizedTransaction));
JobParameters jobParameters = new JobParametersBuilder()
.addString("requestDate", "2024-10-23T14:30:45.123")
.toJobParameters();
//when
jobLauncherTestUtils.launchJob(jobParameters);
//then
List<Settlement> all = settlementRepository.findAll();
Settlement settlement = all.get(0);
Assertions.assertThat(settlement.getTotalSales()).isEqualTo(100000);
Assertions.assertThat(settlement.getNetSales()).isEqualTo(80000);
}
}
[첫번째 시나리오]
가게별_통계_기능_통합_테스트 에서는 총 10개의 가게(Shop) 을 만들고 하나의 가게에는 총 10개의 거래 내역을 저장할 겁니다. 거래 내역에 대한 정보는 NormalizedTransactionHelper 에서 만듭니다.
배치 작업이 정상적으로 작동하면 총 10개의 통계정보(Settlement)가 테이블에 저장될겁니다.
[두번째 시나리오]
NormalizedTransactionHelper 에서 만든 거래 정보는 VIP_DISCOUNT(Vip 할인 혜택) 과 한건의 거래당 발생하는 수수료(1000) 이 적용되어 통계처리가 될겁니다.
VIP 할인 혜택 : 총 주문금액의 10% 할인
거래 수수료 : 1000원
그렇다면 총10개의 거래 내역이 있고 NormalizedTransactionHelper 를 참고해 예상 금액을 계산해보면 총 주문금액은 100000 원, 할인이 적용된 금액은 80000원이 되어야 합니다.
테스트 코드를 실행해보면 ??
짠! 정상적으로 잘 실행되는 것을 확인할 수 있습니다.
🎯 매달 실행되는 정산 시스템 Job
이번에 만들어볼 Job 은 매달 실행되는 정산 배치 Job 입니다. 매일 저장되는 Settlement 를 이용해 MonthlySettlement 라는 새로운 객체를 만들어 DB 에 저장해야 합니다.
✅ MonthlySettlement
package com.domain.settlement.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.UuidGenerator;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* 월별 정산 내역을 나타낼 entity
*/
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class MonthlySettlement {
@Id
@UuidGenerator
@Column(name = "monthly_settlement_id", nullable = false, updatable = false)
private UUID id;
@Column(name = "shop_id", nullable = false)
private UUID shopId;
@Column(name = "shop_name", nullable = false)
private String shopName;
@Column(name = "settlement_date_time", nullable = false)
private LocalDateTime settlementDateTime;
@Column(name = "total_sales", nullable = true)
private double totalSales; // 총 매출
@Column(name = "total_refunds", nullable = true)
private double totalRefunds; // 총 환불 금액
@Column(name = "net_sales", nullable = true)
private double netSales; // 순 매출 (수수료 및 할인이 반영된 금액)
}
한달동안의 정산 정보를 저장할 Entity 입니다. 일별 정산 내역을 나타내는 Settlement 와 크게 다르지 않습니다.
✅ MonthlySettlementComponents
package com.etl;
import com.domain.settlement.entity.MonthlySettlement;
import com.domain.settlement.entity.Settlement;
import com.dto.SettlementAggregation;
import com.parameters.DateParameter;
import jakarta.persistence.EntityManagerFactory;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.database.JpaItemWriter;
import org.springframework.batch.item.database.JpaPagingItemReader;
import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder;
import java.time.LocalDateTime;
import java.util.Map;
public class MonthlySettlementComponents {
public static JpaPagingItemReader<SettlementAggregation> monthlySettlementReader(EntityManagerFactory entityManagerFactory, DateParameter jobParameter) {
String query = """
SELECT new com.dto.SettlementAggregation(
s.shopId,
s.shopName,
SUM(s.totalRefunds),
SUM(s.totalSales),
SUM(s.netSales),
MAX(s.settlementDateTime)
)
FROM Settlement s
WHERE FUNCTION('YEAR', s.settlementDateTime) = :year
AND FUNCTION('MONTH', s.settlementDateTime) = :month
GROUP BY s.shopId
""";
return new JpaPagingItemReaderBuilder<SettlementAggregation>()
.name("monthlySettlementReader")
.entityManagerFactory(entityManagerFactory)
.pageSize(100)
.queryString(query)
.parameterValues(Map.of(
"year", jobParameter.getRequestDate().getYear(),
"month", jobParameter.getRequestDate().getMonthValue()
))
.build();
}
public static ItemProcessor<SettlementAggregation, MonthlySettlement> monthlySettlementProcessor() {
return aggregation -> {
return MonthlySettlement.builder()
.shopId(aggregation.getShopId())
.shopName(aggregation.getShopName())
.settlementDateTime(LocalDateTime.now())
.totalRefunds(aggregation.getTotalRefunds())
.totalSales(aggregation.getTotalSales())
.netSales(aggregation.getNetSales())
.build();
};
}
public static JpaItemWriter<MonthlySettlement> monthlySettlementJpaItemWriter(EntityManagerFactory entityManagerFactory) {
JpaItemWriter<MonthlySettlement> writer = new JpaItemWriter<>();
writer.setEntityManagerFactory(entityManagerFactory);
return writer;
}
}
월별 정산 정보의 Reader, Processor, Writer 를 정의한 클래스 입니다. reader 에서는 JobParamer 로 넘어온 날짜를 이용해 이미 정산된 Settlement 를 조회합니다. 일별 정산 내역을 조회할때 처럼 SettlementAggrement 라는 DTO 에 정산 정보를 조회합니다.
즉, shopId 를 이용해 그룹화를 진행하고 해당 달에 포함된 모든 정산 정보를 합산하는 과정입니다.
✅ MonthlySettlementJobConfig
package com.job;
import com.domain.settlement.entity.MonthlySettlement;
import com.dto.SettlementAggregation;
import com.parameters.DateParameter;
import jakarta.persistence.EntityManagerFactory;
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.configuration.annotation.JobScope;
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
import static com.etl.MonthlySettlementComponents.*;
/**
* 월별 정산 Job
*/
@Slf4j
@RequiredArgsConstructor
@Configuration
public class MonthlySettlementJobConfig {
private static final String JOB_NAME = "monthlySettlementJob";
private static final String STEP_NAME = "monthlySettlementStep";
private final JobRepository jobRepository;
private final PlatformTransactionManager transactionManager;
private final EntityManagerFactory entityManagerFactory;
private final DateParameter jobParameter;
@Bean("monthlySettlementParameter")
@JobScope
public DateParameter monthlySettlementParameter() {
return new DateParameter();
}
@Bean
public Job monthlySettlementJob() {
return new JobBuilder(JOB_NAME, jobRepository)
.start(monthlySettlementStep())
.build();
}
@Bean
@JobScope
public Step monthlySettlementStep() {
return new StepBuilder(STEP_NAME, jobRepository)
.<SettlementAggregation, MonthlySettlement>chunk(100, transactionManager)
.reader(monthlySettlementReader(entityManagerFactory, jobParameter))
.processor(monthlySettlementProcessor())
.writer(monthlySettlementJpaItemWriter(entityManagerFactory))
.build();
}
}
SettlementCalculationJobConfig 와 거의 비슷하게 만들어 자세한 설명은 생략하겠습니다.
자! 그럼 이제 만든 배치 Job 을 스케줄러를 이용해 지정된 시간에 실행되도록 만들어 보겠습니다.
✅ DailySettlementScheduler
package com.scheduler;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import java.time.LocalDateTime;
@Configuration
@EnableScheduling
public class DailySettlementScheduler {
private final JobLauncher jobLauncher;
private final Job settlementCalculationJob;
@Autowired
public DailySettlementScheduler(JobLauncher jobLauncher, @Qualifier("settlementJob") Job settlementCalculationJob) {
this.jobLauncher = jobLauncher;
this.settlementCalculationJob = settlementCalculationJob;
}
@Scheduled(cron = "0 0 3 * * ?")
public void runSettlementCalculationJob() {
try {
JobParameters jobParameters = new JobParametersBuilder()
.addString("requestDate", LocalDateTime.now().toString())
.toJobParameters();
jobLauncher.run(settlementCalculationJob, jobParameters);
} catch (Exception e) {
e.printStackTrace();
}
}
}
settlementCalculationJob 은 매일 새벽 3시 정각에 실행되어야 합니다. @Scheduled 를 이용해 실행될 시간을 지정하고 현재 시간을 String 으로 변환해 JobParameter 로 넘겨줬습니다.
✅ MonthlySettlementScheduler
package com.scheduler;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import java.time.LocalDateTime;
@Configuration
@EnableScheduling
public class MonthlySettlementScheduler {
private final JobLauncher jobLauncher;
private final Job monthlySettlementJob;
@Autowired
public MonthlySettlementScheduler(JobLauncher jobLauncher, @Qualifier("monthlySettlementJob") Job monthlySettlementJob) {
this.jobLauncher = jobLauncher;
this.monthlySettlementJob = monthlySettlementJob;
}
@Scheduled(cron = "0 0 3 1 * ?")
public void runSettlementCalculationJob() {
try {
JobParameters jobParameters = new JobParametersBuilder()
.addString("requestDate", LocalDateTime.now().toString())
.toJobParameters();
jobLauncher.run(monthlySettlementJob, jobParameters);
} catch (Exception e) {
e.printStackTrace();
}
}
}
매달 실행될 정산 시스템 Job 또한 새벽 3시에 실행되어야 합니다.
참고로 Spring Batch 에서 JobLauncher 은 여러 개의 배치 작업을 병렬로 실행할 수 있습니다. 기본적으로 새로운 스레드를 생성해 각각의 Job 을 실행하므로 두개의 배치 작업이 동시에 실행이 가능합니다.
하지만 두 배치 작업이 동일한 시점에 실행되는 경우 둘다 동일한 리소스(DB) 를 사용하는 경우 성능 저하나 경합 상황이 발생할 수 있기에 이런 실무에선 이러한 상황도 고려해야 할거 같습니다.
https://github.com/sinminseok/settlement-system
GitHub - sinminseok/settlement-system
Contribute to sinminseok/settlement-system development by creating an account on GitHub.
github.com
(전체 코드는 위 깃허브 주소를 참고해주세요)
'Spring > Spring Batch' 카테고리의 다른 글
[Spring Batch] JobParameter 날짜 변환 Tip (0) | 2025.01.07 |
---|---|
[Spring Batch] Spring Batch 로 정산 시스템 만들어보기 (4) (1) | 2024.09.02 |
[Spring Batch] Spring Batch 로 정산 시스템 만들어보기 (2) (0) | 2024.08.21 |
[Spring Batch] Spring Batch 로 정산 시스템 만들어보기 (1) (0) | 2024.08.20 |
[Spring Batch] Spring Batch 5 통합 테스트 설계 방법 (0) | 2024.08.15 |