JPA의 동작 방식 (feat. 1차 캐시)

SexyWoong·2024년 9월 26일
0

spring

목록 보기
11/11
post-thumbnail

OrderServiceTest.java

package sample.cafekiosk.spring.api.service.order;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import sample.cafekiosk.spring.api.controller.order.request.OrderCreateRequest;
import sample.cafekiosk.spring.api.service.order.response.OrderResponse;
import sample.cafekiosk.spring.domain.order.OrderRepository;
import sample.cafekiosk.spring.domain.orderproduct.OrderProductRepository;
import sample.cafekiosk.spring.domain.product.Product;
import sample.cafekiosk.spring.domain.product.ProductRepository;
import sample.cafekiosk.spring.domain.product.ProductSellingStatus;
import sample.cafekiosk.spring.domain.product.ProductType;
import sample.cafekiosk.spring.domain.stock.Stock;
import sample.cafekiosk.spring.domain.stock.StockRepository;

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

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
import static sample.cafekiosk.spring.domain.product.ProductType.*;

@SpringBootTest
@ActiveProfiles("test")
@Transactional
//@DataJpaTest
class OrderServiceTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private OrderProductRepository orderProductRepository;

    @Autowired
    private StockRepository stockRepository;

    @Test
    @DisplayName("재고와 관련된 상품이 포함되어있는 주문번호 리스트를 받아 주문을 생성한다.")
    void createOrderWithStock() throws Exception {
        //given
        Product product1 = createProduct("001", "아메리카노", 4000, BOTTLE);
        Product product2 = createProduct("002", "라떼", 4500, BAKERY);
        Product product3 = createProduct("003", "팥빙수", 7000, HANDMADE);
        productRepository.saveAll(List.of(product1, product2, product3));

        Stock stock1 = Stock.create("001", 2);
        Stock stock2 = Stock.create("002", 2);
        stockRepository.saveAll(List.of(stock1, stock2));


        OrderCreateRequest request = OrderCreateRequest.builder()
            .productNumbers(List.of("001", "001", "002", "003"))
            .build();

        LocalDateTime registeredDateTime = LocalDateTime.now();

        //when
        OrderResponse orderResponse = orderService.createOrder(request.toServiceRequest(), registeredDateTime);

        //then
        assertThat(orderResponse.getId()).isNotNull();
        assertThat(orderResponse)
            .extracting("registeredDateTime", "totalPrice")
            .contains(registeredDateTime, 19500);
        assertThat(orderResponse.getProducts()).hasSize(4)
            .extracting("productNumber", "price")
            .containsExactlyInAnyOrder(
                tuple("001", 4000),
                tuple("001", 4000),
                tuple("002", 4500),
                tuple("003", 7000)
            );

        List<Stock> stocks = stockRepository.findAll();
        assertThat(stocks).hasSize(2)
            .extracting("productNumber", "quantity")
            .containsExactlyInAnyOrder(
                tuple("001", 0),
                tuple("002", 1)
            );
    }

    private Product createProduct(String productNumber, String name, int price, ProductType productType) {
        return Product.builder()
            .productNumber(productNumber)
            .type(productType)
            .sellingStatus(ProductSellingStatus.SELLING)
            .name(name)
            .price(price)
            .build();
    }

}

OrderService.java

package sample.cafekiosk.spring.api.service.order;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import sample.cafekiosk.spring.api.controller.order.request.OrderCreateRequest;
import sample.cafekiosk.spring.api.service.order.request.OrderCreateServiceRequest;
import sample.cafekiosk.spring.api.service.order.response.OrderResponse;
import sample.cafekiosk.spring.api.service.product.response.ProductResponse;
import sample.cafekiosk.spring.domain.order.Order;
import sample.cafekiosk.spring.domain.order.OrderRepository;
import sample.cafekiosk.spring.domain.product.Product;
import sample.cafekiosk.spring.domain.product.ProductRepository;
import sample.cafekiosk.spring.domain.product.ProductType;
import sample.cafekiosk.spring.domain.stock.Stock;
import sample.cafekiosk.spring.domain.stock.StockRepository;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import static sample.cafekiosk.spring.domain.product.ProductType.*;

@Transactional
@Service
@RequiredArgsConstructor
public class OrderService {

    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;
    private final StockRepository stockRepository;

    public OrderResponse createOrder(OrderCreateServiceRequest request, LocalDateTime registeredDateTime) {
        List<String> productNumbers = request.getProductNumbers();

        List<Product> duplicateProductNumbers = findProductsBy(productNumbers);

        minusStockQuantities(duplicateProductNumbers);

        Order order = Order.create(duplicateProductNumbers, registeredDateTime);
        Order savedOrder = orderRepository.save(order);

        return OrderResponse.of(savedOrder);
    }

    private void minusStockQuantities(List<Product> duplicateProductNumbers) {
        List<String> stockProductNumbers = extractStockProductNumbers(duplicateProductNumbers);

        List<Stock> stocks = stockRepository.findAllByProductNumberIn(stockProductNumbers);

        Map<String, Long> productCountingMap = createCountingMapBy(stockProductNumbers);

        for (Stock stock : stocks) {
            int minusQuantity = productCountingMap.get(stock.getProductNumber()).intValue();

            if (stock.isQuantityLessThan(minusQuantity)) {
                throw new IllegalArgumentException("재고가 부족한 상품이 있습니다.");
            }

            stock.minusQuantity(minusQuantity);
        }
    }

    private static Map<String, Long> createCountingMapBy(List<String> stockProductNumbers) {
        return stockProductNumbers.stream()
            .collect(Collectors.groupingBy(p -> p, Collectors.counting()));
    }

    private static List<String> extractStockProductNumbers(List<Product> duplicateProductNumbers) {
        return duplicateProductNumbers.stream()
            .filter(product -> ProductType.containsStockType(product.getType()))
            .map(Product::getProductNumber)
            .collect(Collectors.toList());
    }

    private List<Product> findProductsBy(List<String> productNumbers) {
        List<Product> products = productRepository.findAllByProductNumberIn(productNumbers);
        Map<String, Product> productMap = products.stream()
            .collect(Collectors.toMap(Product::getProductNumber, product -> product));

        return productNumbers.stream()
            .map(productMap::get)
            .collect(Collectors.toList());
    }
}

createOrderWithStock 테스트를 실행시켰을때의 로그

WARNING: A Java agent has been loaded dynamically (/Users/gimjaeung/.gradle/caches/modules-2/files-2.1/net.bytebuddy/byte-buddy-agent/1.14.19/154da3a65b4f4a909d3e5bdec55d1b2b4cbb6ce1/byte-buddy-agent-1.14.19.jar)
WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning
WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information
WARNING: Dynamic loading of agents will be disallowed by default in a future release
Hibernate: 
    insert 
    into
        product
        (created_date_time, modified_date_time, name, price, product_number, selling_status, type, id) 
    values
        (?, ?, ?, ?, ?, ?, ?, default)
Hibernate: 
    insert 
    into
        product
        (created_date_time, modified_date_time, name, price, product_number, selling_status, type, id) 
    values
        (?, ?, ?, ?, ?, ?, ?, default)
Hibernate: 
    insert 
    into
        product
        (created_date_time, modified_date_time, name, price, product_number, selling_status, type, id) 
    values
        (?, ?, ?, ?, ?, ?, ?, default)
Hibernate: 
    insert 
    into
        stock
        (created_date_time, modified_date_time, product_number, quantity, id) 
    values
        (?, ?, ?, ?, default)
Hibernate: 
    insert 
    into
        stock
        (created_date_time, modified_date_time, product_number, quantity, id) 
    values
        (?, ?, ?, ?, default)
Hibernate: 
    select
        p1_0.id,
        p1_0.created_date_time,
        p1_0.modified_date_time,
        p1_0.name,
        p1_0.price,
        p1_0.product_number,
        p1_0.selling_status,
        p1_0.type 
    from
        product p1_0 
    where
        p1_0.product_number in (?, ?, ?, ?)
Hibernate: 
    select
        s1_0.id,
        s1_0.created_date_time,
        s1_0.modified_date_time,
        s1_0.product_number,
        s1_0.quantity 
    from
        stock s1_0 
    where
        s1_0.product_number in (?, ?, ?)
Hibernate: 
    insert 
    into
        orders
        (created_date_time, modified_date_time, order_status, registered_date_time, total_price, id) 
    values
        (?, ?, ?, ?, ?, default)
Hibernate: 
    insert 
    into
        order_product
        (created_date_time, modified_date_time, order_id, product_id, id) 
    values
        (?, ?, ?, ?, default)
Hibernate: 
    insert 
    into
        order_product
        (created_date_time, modified_date_time, order_id, product_id, id) 
    values
        (?, ?, ?, ?, default)
Hibernate: 
    insert 
    into
        order_product
        (created_date_time, modified_date_time, order_id, product_id, id) 
    values
        (?, ?, ?, ?, default)
Hibernate: 
    insert 
    into
        order_product
        (created_date_time, modified_date_time, order_id, product_id, id) 
    values
        (?, ?, ?, ?, default)
Hibernate: 
    update
        stock 
    set
        created_date_time=?,
        modified_date_time=?,
        product_number=?,
        quantity=? 
    where
        id=?
Hibernate: 
    update
        stock 
    set
        created_date_time=?,
        modified_date_time=?,
        product_number=?,
        quantity=? 
    where
        id=?
Hibernate: 
    select
        s1_0.id,
        s1_0.created_date_time,
        s1_0.modified_date_time,
        s1_0.product_number,
        s1_0.quantity 
    from
        stock s1_0

로그를 분석하며 2가지의 의문점이 생겼다.

  1. 처음에 엔티티들을 save하는 과정에서 영속성 컨텍스트에 product와 stock가 저장되어 있을텐데 왜 다음과 같은 select 쿼리들이 발생하지?
Hibernate: 
    select
        p1_0.id,
        p1_0.created_date_time,
        p1_0.modified_date_time,
        p1_0.name,
        p1_0.price,
        p1_0.product_number,
        p1_0.selling_status,
        p1_0.type 
    from
        product p1_0 
    where
        p1_0.product_number in (?, ?, ?, ?)
Hibernate: 
    select
        s1_0.id,
        s1_0.created_date_time,
        s1_0.modified_date_time,
        s1_0.product_number,
        s1_0.quantity 
    from
        stock s1_0 
    where
        s1_0.product_number in (?, ?, ?)
  1. 테스트 코드에 @Transactional이 붙었으면 테스트 수행 후 rollback이 된다. 그렇다면 dirty checking이 발생하지 않을텐데 왜 update 쿼리가 발생한걸까?

1. 처음에 엔티티들을 save하는 과정에서 영속성 컨텍스트에 product와 stock가 저장되어 있을텐데 왜 다음과 같은 select 쿼리들이 발생하지?

1. 영속성 컨텍스트와 1차 캐시

  • Spring Data JPA에서 엔티티를 save하면 해당 엔티티는 영속성 컨텍스트에 의해 관리된다.

  • 영속성 컨텍스트는 1차 캐시 역할을 하며, 현재 트랜잭션 내에서 동일한 엔티티에 대한 중복 조회를 방지한다.

  • 이 캐시는 엔티티를 기본 키(ID)를 기준으로 관리한다.

2. ID로 조회하는 경우

  • findById() 메서드 등을 통해 ID로 엔티티를 조회하면, 영속성 컨텍스트에 해당 ID의 엔티티가 존재하는지 먼저 확인한다.

  • 이미 영속성 컨텍스트에 엔티티가 존재하면 데이터베이스에 쿼리를 보내지 않고 캐시된 엔티티를 반환한다.
    따라서 select 쿼리가 발생하지 않는다.

3. 기본 키 이외의 속성으로 조회하는 경우

  • findByProductNumber()와 같이 기본 키가 아닌 속성으로 엔티티를 조회하면, 영속성 컨텍스트의 1차 캐시를 활용할 수 없다.

  • 이는 1차 캐시가 기본 키로만 엔티티를 식별하고 관리하기 때문이다.

  • 따라서 해당 속성으로 엔티티를 찾기 위해 새로운 select 쿼리가 데이터베이스에 전송된다.

정리

  • 영속성 컨텍스트는 엔티티를 기본 키(ID)를 기준으로 관리한다.
  • 기본 키로 조회할 때는 영속성 컨텍스트에서 캐시된 엔티티를 반환하여 데이터베이스 조회를 하지 않는다.
  • 기본 키 이외의 속성으로 조회할 때는 캐시를 활용할 수 없으므로 데이터베이스에 쿼리를 전송한다.

2. 테스트 코드에 @Transactional이 붙었으면 테스트 수행 후 rollback이 된다. 그렇다면 dirty checking이 발생하지 않을텐데 왜 update 쿼리가 발생한걸까?

우선 findAll()의 동작 방식에 대해서 알아보자.

1차 캐시

JPA의 영속성 컨텍스트는 엔티티의 상태를 1차 캐시에 저장한다. 따라서, 엔티티를 처음 저장할 때(예: saveAll() 메서드를 호출할 때), 해당 엔티티는 1차 캐시에 올라간다.

이후에 기본 키(예: id)로 조회하는 경우, JPA는 먼저 1차 캐시에서 엔티티를 찾고, 1차 캐시에서 찾지 못하면 데이터베이스에 쿼리를 전송한다.

findAll()이 1차 캐시를 사용하지 않는 이유

1차 캐시는 엔티티의 ID(기본 키)를 기준으로 관리된다.

그런데 findAll()은 모든 엔티티를 조회하는 것이므로, 1차 캐시에 저장된 엔티티가 있더라도 JPA는 이를 무조건 1차 캐시에서 찾지 않고, 데이터베이스에 SELECT 쿼리를 보낸다.

특히, 영속성 컨텍스트에서 더티 체킹이나 변경 사항이 발생한 엔티티가 있는 경우, JPA는 최신 상태를 유지하기 위해 flush()를 실행하고, 엔티티의 상태가 바뀌었는지 확인하기 위해 데이터베이스에 직접 쿼리를 보낸다.

findAll()이 왜 데이터베이스에 직접 select 쿼리를 보내는가

  • findAll()은 모든 엔티티를 가져오므로, 1차 캐시의 엔티티만을 사용하는 것이 아니라, 데이터베이스와의 일관성을 유지하기 위해 데이터베이스에 SELECT 쿼리를 전송해 최신 데이터를 가져온다.

  • 특히, Stock 엔티티의 상태가 변경되었거나 영속성 컨텍스트에서 관리 중인 엔티티의 상태가 최신이 아닐 수 있는 상황에서는, JPA는 데이터베이스에 SELECT 쿼리를 보내 최신 상태를 확인한다.

결론

findAll()메서드가 호출된 경우, 해당 엔티티에 더티 체킹이 발생한 상황에서는 JPA가 엔티티의 상태를 최신으로 유지하기 위해 데이터베이스와 동기화하려고 flush()후 select쿼리를 전송한다.

마지막 findAll()메서드를 주석처리하고 로그를 확인해보았다.

WARNING: A Java agent has been loaded dynamically (/Users/gimjaeung/.gradle/caches/modules-2/files-2.1/net.bytebuddy/byte-buddy-agent/1.14.19/154da3a65b4f4a909d3e5bdec55d1b2b4cbb6ce1/byte-buddy-agent-1.14.19.jar)
WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning
WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information
WARNING: Dynamic loading of agents will be disallowed by default in a future release
Hibernate: 
    insert 
    into
        product
        (created_date_time, modified_date_time, name, price, product_number, selling_status, type, id) 
    values
        (?, ?, ?, ?, ?, ?, ?, default)
Hibernate: 
    insert 
    into
        product
        (created_date_time, modified_date_time, name, price, product_number, selling_status, type, id) 
    values
        (?, ?, ?, ?, ?, ?, ?, default)
Hibernate: 
    insert 
    into
        product
        (created_date_time, modified_date_time, name, price, product_number, selling_status, type, id) 
    values
        (?, ?, ?, ?, ?, ?, ?, default)
Hibernate: 
    insert 
    into
        stock
        (created_date_time, modified_date_time, product_number, quantity, id) 
    values
        (?, ?, ?, ?, default)
Hibernate: 
    insert 
    into
        stock
        (created_date_time, modified_date_time, product_number, quantity, id) 
    values
        (?, ?, ?, ?, default)
Hibernate: 
    select
        p1_0.id,
        p1_0.created_date_time,
        p1_0.modified_date_time,
        p1_0.name,
        p1_0.price,
        p1_0.product_number,
        p1_0.selling_status,
        p1_0.type 
    from
        product p1_0 
    where
        p1_0.product_number in (?, ?, ?, ?)
Hibernate: 
    select
        s1_0.id,
        s1_0.created_date_time,
        s1_0.modified_date_time,
        s1_0.product_number,
        s1_0.quantity 
    from
        stock s1_0 
    where
        s1_0.product_number in (?, ?, ?)
Hibernate: 
    insert 
    into
        orders
        (created_date_time, modified_date_time, order_status, registered_date_time, total_price, id) 
    values
        (?, ?, ?, ?, ?, default)
Hibernate: 
    insert 
    into
        order_product
        (created_date_time, modified_date_time, order_id, product_id, id) 
    values
        (?, ?, ?, ?, default)
Hibernate: 
    insert 
    into
        order_product
        (created_date_time, modified_date_time, order_id, product_id, id) 
    values
        (?, ?, ?, ?, default)
Hibernate: 
    insert 
    into
        order_product
        (created_date_time, modified_date_time, order_id, product_id, id) 
    values
        (?, ?, ?, ?, default)
Hibernate: 
    insert 
    into
        order_product
        (created_date_time, modified_date_time, order_id, product_id, id) 
    values
        (?, ?, ?, ?, default)

update 쿼리가 발생하지 않은것을 확인할 수 있다.

profile
함께 있고 싶은 사람, 함께 일하고 싶은 개발자

0개의 댓글