2024.03.28 - [Spring/결제] - Spring으로 결제서비스 만들기(3)
이전까지 일일 정산 배치에 대해서 알아보았다.
이번에는 마지막으로 주간정산 배치를 만들어보겠다.
이제까지 구현했던 코드에 주간정산하는 날이면 주간정산을 실행시켜줘라는 조건을 추가로 걸어줘야한다.
이전에 배웠던 FlowJob을 활용할 수도있지만, JobExecutionDecider로도 조건을 걸어줄수있다.
//매주 금요일마다 주간 정산을 한다.
public JobExecutionDecider isFridayDecider(){
return (jobExecution, stepExecution) -> {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd");
String targetDate = stepExecution.getJobParameters().getString("targetDate");
LocalDate date = LocalDate.parse(targetDate, formatter);
if(date.getDayOfWeek() != DayOfWeek.FRIDAY){
return new FlowExecutionStatus("NOOP");
}
return FlowExecutionStatus.COMPLETED;
};
}
@Bean
public Job settleJob(
Step preSettleDetailStep,
Step settleDetailStep,
Step settleGroupStep
){
return new JobBuilder("settleJob", jobRepository)
.validator(new DateFormatJobParametersValidator(new String[]{"targetDate"}))
.start(preSettleDetailStep)
.next(settleDetailStep)
.next(isFridayDecider())
.on("COMPLTED").to(settleGroupStep)
.build()
//주간정산하는 날이면 주간정산 실행!
.build();
}
이렇게 매주 금요일인지 검증하는 부분을 추가해주었고, 이제는 settleGroup 도메인과 settleGroupStep에 대해서 구현해야한다.
SettleGroup.java
@Getter
@Entity
@NoArgsConstructor
@ToString
public class SettleGroup {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long customerId;
private String serviceId;
private Long totalCount;
private Long totalFee;
private LocalDateTime createdAt;
public SettleGroup(Long customerId, String serviceId, Long totalCount, Long totalFee, LocalDateTime createdAt) {
this.customerId = customerId;
this.serviceId = serviceId;
this.totalCount = totalCount;
this.totalFee = totalFee;
this.createdAt = createdAt;
}
}
다음으로 Step을 구현한다.
메일도 보내야하고 DB에도 저장해야하고, 두가지 작업이 모두 이루어져야하기 때문에 Composite패턴을 활용한다.
Composite패턴이란? 간단하게 여러가지 객체를 한번에 담아둘수있는 폴더처럼 구성하는것을 의미하는 패턴이다.
SettleGroupStepConfiguration.java
@Configuration
@RequiredArgsConstructor
public class SettleGroupStepConfiguration {
private final JobRepository jobRepository;
private final PlatformTransactionManager platformTransactionManager;
@Bean
public Step settleGroupStep(
SettleGroupReader settleGroupReader,
SettleGroupProcessor settleGroupProcessor,
ItemWriter<List<SettleGroup>> itemWriter
){
return new StepBuilder("settleGroupStep", jobRepository)
.<Customer, List<SettleGroup>>chunk(100, platformTransactionManager)
.reader(settleGroupReader)
.processor(settleGroupProcessor)
.writer(itemWriter)
.build();
}
@Bean
public ItemWriter<List<SettleGroup>> settleGroupItemWriter(
SettleGroupItemDBWriter settleGroupItemDBWriter,
SettleGroupItemMailWriter settleGroupItemMailWriter
){
return new CompositeItemWriter<>(
settleGroupItemDBWriter,
settleGroupItemMailWriter
);
}
}
ItemWriter에서 Composite 패턴을 활용하기 위해 이미 스프링 프레임워크에서 제공하고 있는 기능을 사용했다.
리스트로 인자를 받아서 만약 더 Writer에서 추가적인 요구조건이 있거나, 필요없어진 요구조건이있다면, 인자 부분만 수정해서 응집력있게 구현할 수 있다.
또한 chunk부분에서 입력으로 Customer를 받을 것이니 Customer 도메인 부분도 작성한다.
@Data
public class Customer {
private Long id;
private String name;
private String mail;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Customer(Long id, String name, String mail) {
this.id = id;
this.name = name;
this.mail = mail;
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
}
※ 정산팀은 고객의 데이터를 가지고 있지 않다고 가정한다.
전체적인 뼈대는 어느정도 갖춰진것 같다. 이제부터 차례대로 상세히 구현해볼것이다.
위에서 조건으로 정산팀은 고객의 데이터를 직접가지고 있는 것이아니라, 다른 곳에서 들고와야하기때문에 Repository를 임의로 하나 만든다.
public interface CustomerRepository {
List<Customer>findALl(Pageable pageable);
Customer findById(Long id);
class Fake implements CustomerRepository{
@Override
public List<Customer> findALl(Pageable pageable) {
if(pageable.getPageNumber() == 0){
return List.of(
new Customer(0L, "A사", "A@company.com"),
new Customer(1L, "B사", "B@company.com"),
new Customer(2L, "C사", "C@company.com"),
new Customer(3L, "D사", "D@company.com"),
new Customer(4L, "E사", "E@company.com"),
new Customer(5L, "F사", "F@company.com"),
new Customer(6L, "G사", "G@company.com"),
new Customer(7L, "H사", "H@company.com"),
new Customer(8L, "I사", "I@company.com"),
new Customer(9L, "J사", "J@company.com")
);
} else if (pageable.getPageNumber() == 1) {
return List.of(
new Customer(10L, "K사", "K@company.com"),
new Customer(11L, "L사", "L@company.com"),
new Customer(12L, "M사", "M@company.com"),
new Customer(13L, "N사", "N@company.com"),
new Customer(14L, "O사", "O@company.com"),
new Customer(15L, "P사", "P@company.com"),
new Customer(16L, "Q사", "Q@company.com"),
new Customer(17L, "R사", "R@company.com"),
new Customer(18L, "S사", "S@company.com"),
new Customer(19L, "T사", "T@company.com")
);
}else{
return Collections.emptyList();
}
}
@Override
public Customer findById(Long id) {
return Stream.of(
new Customer(0L, "A사", "A@company.com"),
new Customer(1L, "B사", "B@company.com"),
new Customer(2L, "C사", "C@company.com"),
new Customer(3L, "D사", "D@company.com"),
new Customer(4L, "E사", "E@company.com"),
new Customer(5L, "F사", "F@company.com"),
new Customer(6L, "G사", "G@company.com"),
new Customer(7L, "H사", "H@company.com"),
new Customer(8L, "I사", "I@company.com"),
new Customer(9L, "J사", "J@company.com"),
new Customer(10L, "K사", "K@company.com"),
new Customer(11L, "L사", "L@company.com"),
new Customer(12L, "M사", "M@company.com"),
new Customer(13L, "N사", "N@company.com"),
new Customer(14L, "O사", "O@company.com"),
new Customer(15L, "P사", "P@company.com"),
new Customer(16L, "Q사", "Q@company.com"),
new Customer(17L, "R사", "R@company.com"),
new Customer(18L, "S사", "S@company.com"),
new Customer(19L, "T사", "T@company.com")
).filter(it -> it.getId().equals(id))
.findFirst()
.orElseThrow();
}
}
}
findAll과 findById로 여러개 혹은 단일 Customer에 대해서 읽을 수있도록 구현했다.
ItemReader
SettleGroupReader.java
@Component
public class SettleGroupReader implements ItemReader<Customer> {
private final CustomerRepository customerRepository;
private Iterator<Customer> customerIterator;
private int pageNo = 0;
public SettleGroupReader() {
this.customerRepository = new CustomerRepository.Fake();
customerIterator = Collections.emptyIterator();
}
@Override
public Customer read() {
if(customerIterator.hasNext())
return customerIterator.next();
customerIterator = customerRepository.findALl(PageRequest.of(pageNo++, 10)).iterator();
if(!customerIterator.hasNext())
return null;
return customerIterator.next();
}
}
Page로 10개씩 데이터를 받아와서, iterator로 순회한다. 물론 임의로 생성한 Fake객체를 받아와서 읽는다.
ItemProcessor
프로세서에서는 내가 원하는 조건인 일주일동안의 정산을 하기 위해서 필터링을 해준다.
이를 위해서 따로 레포지토리가 필요하다.
SettleGroupRepository.java
public interface SettleGroupRepository extends JpaRepository<SettleGroup, Long> {
@Query(
value = """
SELECT new SettleGroup(detail.customerId, detail.serviceId, sum(detail.count), sum(detail.fee))
FROM SettleDetail detail
WHERE detail.targetDate between :start and :end
AND detail.customerId = :customerId
group by detail.customerId, detail.serviceId
"""
)
List<SettleGroup> findGroupByCustomerIdAndServiceId(LocalDate start, LocalDate end, Long customerId);
}
Reader에서 읽어온 Customer 정보를 인자로 넘겨받아서 그 고객의 아이디, 해당되는 서비스아이디, 횟수, 총금액을 반환하도록 쿼리를 써줬다. 이미 일일 정산 배치에서 구현해서 테이블에 데이터들이 남아있는 SettleDetail에서 해당되는 고객의 정보를 뽑아온다.
SettleGroupProcessor.java
@Component
@RequiredArgsConstructor
public class SettleGroupProcessor implements ItemProcessor<Customer, List<SettleGroup>>, StepExecutionListener {
private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd");
private final SettleGroupRepository settleGroupRepository;
private StepExecution stepExecution;
@Override
public List<SettleGroup> process(Customer item) throws Exception {
String targetDate = stepExecution.getJobParameters().getString("targetDate");
LocalDate end = LocalDate.parse(targetDate, dateTimeFormatter);
return settleGroupRepository.findGroupByCustomerIdAndServiceId(
end.minusDays(6),
end,
item.getId()
);
}
@Override
public void beforeStep(StepExecution stepExecution) {
this.stepExecution = stepExecution;
}
}
레포지토리에서 설정한 함수를 가지고 새롭게 SettleGroup을 리스트로 반환해준다. 물론 당연히 JobParameters는 받아서 targetDate에 맞게끔 프로세싱한다.
ItemWriter
물론 JpaItemWriter를 통해서 간단하게 구현할수도 있지만, 리스트 형태를 제공하고 있지않기 때문에 List형으로 커스터마이징해서 구현한다.
- DB에 저장
SettleGroupItemDBWriter.java
@Component
@RequiredArgsConstructor
public class SettleGroupItemDBWriter implements ItemWriter<List<SettleGroup>> {
private final SettleGroupRepository settleGroupRepository;
@Override
public void write(Chunk<? extends List<SettleGroup>> chunk) throws Exception {
List<SettleGroup> settleGroups = new ArrayList<>();
chunk.forEach(settleGroups::addAll);
settleGroupRepository.saveAll(settleGroups);
}
}
- 메일 보내기
메일을 보내기위해서 임의로 로그만 찍어주는 인터페이스와 클래스를 만들었다.
EmailProvider.java
public interface EmailProvider {
void send(String emailAddress, String title, String body);
@Slf4j
class Fake implements EmailProvider{
@Override
public void send(String emailAddress, String title, String body) {
log.info("{} email 전송 완료! \n{} \n{}", emailAddress, title, body);
}
}
}
SettleGroupItemMailWriter.java
@Component
public class SettleGroupItemMailWriter implements ItemWriter<List<SettleGroup>> {
private final CustomerRepository customerRepository;
private final EmailProvider emailProvider;
public SettleGroupItemMailWriter() {
this.customerRepository = new CustomerRepository.Fake();
this.emailProvider = new EmailProvider.Fake();
}
//유료 API 총 사용횟수, 총 요금
//세부사항에 대해서 (url, 몇건, 얼마)
@Override
public void write(Chunk<? extends List<SettleGroup>> chunk) throws Exception {
for (List<SettleGroup> settleGroups : chunk) {
if (settleGroups.isEmpty()) continue;
SettleGroup settleGroup = settleGroups.get(0);
Long customerId = settleGroup.getCustomerId();
Customer customer = customerRepository.findById(customerId);
Long totalCount = settleGroups.stream().map(SettleGroup::getTotalCount).reduce(0L, Long::sum);
Long totalFee = settleGroups.stream().map(SettleGroup::getTotalFee).reduce(0L, Long::sum);
List<String> detailByService = settleGroups.stream()
.map(it ->
"\n\"%s\" - 총 사용수 : %s, 총 비용 : %s".formatted(
ServicePolicy.findById(it.getServiceId()).getUrl(),
it.getTotalCount(),
it.getTotalFee()
)
)
.toList();
String body = """
안녕하세요 %s 고객님. 사용하신 유료 API 과금안내 드립니다.
총 %s건을 사용하셨으며, %s원의 비용이 발생했습니다.
세부내역은 다음과 같습니다. 감사합니다.
%s
""".formatted(
customer.getName(),
totalCount,
totalFee,
detailByService);
emailProvider.send(customer.getMail(),"유료 API 과금 안내" , body);
}
}
}
고객 정보와, 이메일 정보 모두 임의로 만든 Fake객체로 초기화 시키고 시작한다.
프로세서에서 받은 chunk를 돌면서, 고객 정보와, SettleGroup을 찾는다. 그리고 여기서 사용된 reduce함수는 하나의 값으로 매핑시켜주는 집계함수인데, 여기서는 총합을 의미한다. 0L은 초기값 설정이다. 그리고 Long클래스의 sum함수를 이용해서 총합을 구해줬다.
실행결과
뭔가 엄청나게 많이떴다.
확실한건 로그로 결제 API가 출력되는 것과, DB에 잘 저장되는 것을 확인할 수 있다.
이로써 배치를 활용한 결제 API만들기가 끝이 났다.
사실 혼자서 이렇게 크게 만들고 보니, 점점 헷갈려서 복습을 좀 많이 해봐야겠다,,,^^
'Spring > 결제' 카테고리의 다른 글
Spring으로 결제서비스 만들기(3) (0) | 2024.03.28 |
---|---|
Spring으로 결제서비스 만들기(2) (0) | 2024.03.27 |
Spring으로 결제서비스 만들기(1) (0) | 2024.03.27 |