요구사항 추가
- 관리자가 특정일자를 입력하면, 그 날짜에 해당하는 하루치 매출 통계를 메일로 전송해주는 서비스
- 예) 2023.12.24일 입력하면 2023.12.24일 00:00:00 ~ 12:59:59 이하에 해당하는 매출 통계를 메일로 전송한다.
구현코드
@RequiredArgsConstructor
@Service
public class OrderStatisticsService {
private final OrderRepository orderRepository;
private final MailService mailService;
public boolean sendOrderStatisticsMail(LocalDate orderDate, String email) {
// 해당 일자에 결제완료된 주문들을 가져와서
List<Order> orders = orderRepository.findOrdersBy(
orderDate.atStartOfDay(), orderDate.plusDays(1).atStartOfDay(), OrderStatus.COMPLETE
);
// 총 매출 합계를 계산하고
int totalAmount = orders.stream()
.mapToInt(Order::getTotalPrice)
.sum();
// 메일 전송
boolean result = mailService.sendMail(
"no-reply@cafekiosk.com",
email,
String.format("[매출통계] %s", orderDate),
String.format("총 매출 합계는 %s원입니다.", totalAmount));
if (!result) {
throw new IllegalArgumentException("매출 통계 메일 전송에 실패했습니다.");
}
return true;
}
}
- 메일 전송 서비스로
@Query("select o from Order o where o.registeredDateTime >= :startDateTime and o.registeredDateTime < :endDateTime "
+ "and o.orderStatus = :orderStatus")
List<Order> findOrdersBy(@Param("startDateTime") LocalDateTime startDateTime
, @Param("endDateTime") LocalDateTime endDateTime, @Param("orderStatus") OrderStatus orderStatus);
- Native Query로 작성하였습니다.
- 2023.12.01.11.50.50 -> 이렇게 들어온다면 2023.12.01.00.00.00 ~ 2023.12.01.12.59.59 기간 안의 주문을 가지고 와서, 매출 합계를 계산하고 이 통계를 메일로 전송해주는 로직입니다.
주의해야할 점
외부 API인 메일 전송 기능을 사용하고 있습니다.
- 테스트 하기 어렵지 않을까?
- 트랜잭션을 걸어줘야 할까? (위의 로직에서)
Stubbing 작업을 해주자.
- 외부 API의 경우 성공, 실패 두가지 조건만 존재합니다.
- 성공 되었다 가정하고 테스트를 진행하고
- 실패 되었다 가정하고 테스트를 진행합니다.
@DisplayName("결제완료 주문들을 조회하여 매출 통계 메일을 전송한다.")
@Test
void sendOrderStatisticsMail() {
// given
LocalDateTime now = LocalDateTime.of(2023, 12, 2, 10, 0);
Product product1 = createProduct("001", 1000);
Product product2 = createProduct("002", 2000);
Product product3 = createProduct("003", 3000);
productRepository.saveAll(List.of(product1, product2, product3));
List<Product> products = List.of(product1, product2, product3);
Order order1 = createPaymentCompletedOrder(LocalDateTime.of(2023, 12, 1, 23, 59, 59), products);
Order order2 = createPaymentCompletedOrder(now, products);
Order order3 = createPaymentCompletedOrder(LocalDateTime.of(2023, 12, 2, 23, 59, 59), products);
Order order4 = createPaymentCompletedOrder(LocalDateTime.of(2023, 12, 3, 0, 0), products);
// stubbing
Mockito.when(mailSendClient.sendEmail(any(String.class), any(String.class), any(String.class), any(String.class)))
.thenReturn(true);
// when
boolean result = orderStatisticsService.sendOrderStatisticsMail(LocalDate.of(2023, 12, 2),
"test@test.com");
// then
assertThat(result).isTrue();
List<MailSendHistory> histories = mailSendHistoryRepository.findAll();
assertThat(histories).hasSize(1)
.extracting("content")
.contains("총 매출 합계는 12000원입니다.");
}
@Transactional 어노테이션을 붙여줘야 할까?
위의 경우에서는 "아니"라고 답할 수 있습니다.
- 조회 쿼리가 나갈 때 트랜잭션이 나간다.
- 처음에 그게 뭘까? 했는데 JpaRepository를 타고 타고, CrudRepository의 구현체인 SimpleJpaRepository를 살펴보았습니다.
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
private static final String ID_MUST_NOT_BE_NULL = "The given id must not be null!";
private final JpaEntityInformation<T, ?> entityInformation;
private final EntityManager em;
private final PersistenceProvider provider;
/* findBy, save, saveAll, 등등 */
}
- 기본적으로 쿼리가 나갈 때 트랜잭션이 붙어있습니다.
- 이건 제가 잘못 이해한거 일수도 있습니다.(유의)
- 서비스에 Transactional을 붙여줄 때는 예를 들어 A계좌에서 B계좌에 돈을 입금하려고 할 때, 두 가지 작업이 이루어지게 되는데 출금과 입금이라는 다른 기능이지만 하나의 작업 안에서 수행해야 합니다.
- 쿼리가 나갈 때마다 SimpleJpaRepository에 Transactional이 붙어있어 Service Layer에 안 붙여줘도 되겠구나 -> X
- 라는 이야기 입니다.
- 서비스 Layer에 Transactional을 붙여주지 않아도 각 쿼리마다 Transactional 기능이 작동합니다.
- 예를 들어 출금은 되었는데, 입금이 실패한 경우 입금이 실패했기 때문에 rollback 작업이 수행될 것입니다. 그러나 출금은 이미 실행된 후 입니다. (돈이 없어졌어!)
지금 이 로직을 보시면
public boolean sendOrderStatisticsMail(LocalDate orderDate, String email) {
// 해당 일자에 결제완료된 주문들을 가져와서
List<Order> orders = orderRepository.findOrdersBy(
orderDate.atStartOfDay(), orderDate.plusDays(1).atStartOfDay(), OrderStatus.COMPLETE
);
// 총 매출 합계를 계산하고
int totalAmount = orders.stream()
.mapToInt(Order::getTotalPrice)
.sum();
// 메일 전송
boolean result = mailService.sendMail(
"no-reply@cafekiosk.com",
email,
String.format("[매출통계] %s", orderDate),
String.format("총 매출 합계는 %s원입니다.", totalAmount));
if (!result) {
throw new IllegalArgumentException("매출 통계 메일 전송에 실패했습니다.");
}
return true;
}
- 조회 한번 작업만이 존재할 뿐입니다.
- 여기서 실패해도 DB에 영항을 주지 않습니다. 자체적으로 findOrdersBy할 때 Transactional이 붙어있기도 하고.
- 고려해볼 점은 위의 findOrdersBy에서 Optional으로 반환받아 orders가 Empty할 때 예외를 던져주기 입니다.
느낀점
- 트랜잭션에 대해 한번더 생각해볼 수 있는 계기가 되었습니다.
- 무조건 서비스 레이어에는 트랜잭션을 달아줘야 하는 줄 알았습니다.
- SimpleJpaRepository에 대해 알게 되었습니다.
- 참고 자료
'Spring관련 기술 > 테스트코드' 카테고리의 다른 글
@Mock, @Spy, @InjectMocks (0) | 2023.12.24 |
---|---|
Test Double (0) | 2023.12.24 |
Presentation Layer 테스트 (2) (0) | 2023.12.22 |
Presentation Layer 테스트(1) (0) | 2023.12.19 |
Business Layer 테스트 (2) (0) | 2023.12.17 |