본문 바로가기

spring

[MileStone 프로젝트] SQL 쿼리 최적화와 성능 향상

 

2024년 3월 달 부터 시작해, 현재 여름 방학이 끝나기 전에 나만의 쇼핑몰을 만들고 있으며, 현재는 80%는 진행된 상태이다. 현재까지의 문제점과 내가 맞닥뜨린 난항들은 우선적으로 프로젝트의 완성을 우선 시 하였기에 주석으로 내가 어떻게 해결했고 어떠한 문제점을 겪었는지를 나타내었다.

 

그 중 내가 겪었던 문제 중 하나는 어떠한 기능을 수행하기에 있어서 sql 쿼리가 너무 많이 나온다는 점이다. 전체 상품 조회, 카테고리 별 상품 조회, 상품 상세 페이지 등등.. 명령어 창을 뒤덮을 정도로 쿼리들이 너무 많이 나왔다.

 

SQL쿼리가 많이 발생하면 여러가지 문제점을 발생시킨다. 대표적으로 몇 가지만 말해보자면 "성능 저하" (응답 시간 증가, 데이터베이스 부하), "네트워크 자원 낭비" 등등 많은 문제점을 발생하게 된다.

 

해당문제를 해결하기 위해, 검색 및 책을 통해서 알아낸 방법으로 "지연 로딩 설정" 과 "엔티티 그래프"가 있었고 이를 통해 해결해 보았다. 

 

우선적으로 엔티티 그래프가 무엇이냐면 JPA에서 특정 엔티티를 조회할 때 "함께 로드할 연관 엔티티를 정의"하는 것이다. 이러한 기능을 통해 N + 1 문제를 비롯한 여러 가지 성능 문제의 해결책이 될 수 있다.

 

자세한 것은 코드를 통해 알아보자.

 

public interface ProductRepository extends JpaRepository<Product, Long> {

     @EntityGraph(value = "Product.summary", type = EntityGraph.EntityGraphType.LOAD)
     List<Product> findAll();

     @EntityGraph(value = "Product.summary", type = EntityGraph.EntityGraphType.LOAD)
     List<Product> findByCategoryName(String categoryName);

     @EntityGraph(value = "Product.detail", type = EntityGraph.EntityGraphType.LOAD)
     Optional<Product> findDetailById(@Param("productId") Long productId);
}

@Entity
@Table(name = "PRODUCTS")
@Getter
@NoArgsConstructor
@NamedEntityGraphs({
        @NamedEntityGraph(
                name = "Product.detail",
                attributeNodes = {
                        @NamedAttributeNode("category"),
                        @NamedAttributeNode("reviews"),
                        @NamedAttributeNode(value = "productOptions", subgraph = "productOptions.subgraph")
                },
                subgraphs = {
                        @NamedSubgraph(
                                name = "productOptions.subgraph",
                                attributeNodes = {
                                        @NamedAttributeNode("productColor"),
                                        @NamedAttributeNode("productSize")
                                }
                        )
                }
        ),
        @NamedEntityGraph(
                name = "Product.summary",
                attributeNodes = {
                        @NamedAttributeNode("category")
                }
        )
})
public class Product {
    @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
    private Set<ProductOption> productOptions = new HashSet<>();

    @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
    private Set<Review> reviews = new HashSet<>();
}

 

 

대표적으로 엔티티 그래프를 설정해 둔 코드를 살펴보면 product 엔티티와 레포지토리에 엔티티 그래프를 사용하여 쿼리를 최적화한 예제이다.

 

코드를 보면 알 수 있듯이, 상품 엔티티에 우선적으로 함께 로드가 필요한 연관 엔티티를 @NamedEntityGraphs 어노테이션을 통해 각 분류 별로 나눈 뒤, 각 분류에 맞게 함께 로드가 필요한 엔티티들을 설정해 둔 것을 볼 수 있으며 이를 이용하여 레포지토리에서 메서드 정의 시, 이를 이용하여 연관된 엔티티를 같이 로드시켜 쿼리를 줄일 수 있다.

 

레포지토리 중 findAll() 메서드를 보면 엔티티 그래프 product.summary로 지정한 분야를 선택하였으며 전체 상품 목록을 조회할 때 각 상품의 카테고리 정보를 함께 로드되게 하였으며 이는 기본적으로 지연 로딩으로 설정된 카테고리 정보를 한 번에 가져오게 하여, 각 상품마다 별도의 쿼리를 실행하지 않도록 하였다.

 

다음으로 findDetailById 메서드를 보면, 위의 메서드들과는 같은 엔티티 그래프가 아닌 "Product.detail" 엔티티 그래프를 선택한 것을 볼 수 있으며, 해당 기능 구현 전에는 상당히 많은 쿼리들을 쏟아내었었다. 왜냐하면 엔티티그래프에 설정된 엔티티를 보면 알 수 있듯이 리뷰를 비롯한 상품 옵션 등을 나타내기 위해 쿼리들이 많이 나타났었다.

 

하지만 엔트트 그래프를 설정함으로 인해, 특정 상품의 상세 정보를 조회할 때, 해당 상품의 카테고리, 리뷰, 상품 옵션 정보를 함께 로드하게 만들어 이를 통해 개별적으로 카테고리, 리뷰, 상품 옵션 정보를 조회하는 다수의 쿼리를 줄일 수 있다.

 

 

이외에도 기능을 구현함에 있어, 엔티티 그래프를 설정하였고 쿼리를 작성함에 따라 위의 상품 엔티티처럼 복잡한 엔티티 그래프를 설정함과는 달리 간단하게 설정이 필요할 때에는 따로 엔티티에 작성할 @NamedEntityGraph를 작성할 필요없이 레포지토리에서 attributePaths = {~~} 를 통해 쿼리를 간단히 조작함과 동시에 기능을 수행할 수 있음을 알게 되었다.

 

@Entity
@Table(name = "CART_ITEMS")
@Getter @NoArgsConstructor
public class CartItem {
public interface CartItemRepository extends JpaRepository<CartItem, Long> {
    @EntityGraph(attributePaths = {"productOption.product"})
    List<CartItem> findByMember(Member member);

    @EntityGraph(attributePaths = {"productOption.product"})
    List<CartItem> findByMemberAndIdIn(Member member, List<Long> cartItemIds);

    @EntityGraph(attributePaths = {"productOption.product"})
    Optional<CartItem> findByMemberAndProductOption(Member member, ProductOption productOption);
}

 

보면 알 수 있듯이, 엔티티에 따로 @NamedEntityGraph와 Node를 설정하지 않은 것을 볼 수 있다. 마지막으로 지연로딩 또한 sql 쿼리를 줄이는 데 도움을 주었는데, 사실 지연로딩의 개념은 어떠한 기능을 불러옴에 있어, 필요하지 않은 데이터는 불러오지 아니하고, 필요한 순간에만 호출을 하게 하는 기능으로 기 로딩 시 불필요한 데이터를 로드하지 않음으로써 초기 쿼리 성능을 향상시키고, 메모리 사용을 줄일 수 있는 것으로 알고 있었다.

 

그 후 김영환의 JPA에 관한 저서를 읽는 중 다양한 예시를 통해 어느 사용법에 익숙해졌으며 이를 MileStone 쇼핑몰 프로젝트에 응용하기로 결정하였다.

 

나중에 깃허브에 올라올 소스코드에서 보면 알겠지만 다양한 엔티티에 Lazy 설정을 하였으며 위의 코드에서 상품 엔티티에도 적용을 하였다. 현재 지연 로딩을 통해 상품 엔티티를 처음 로드할 때 연관된 상품 옵션 및 리뷰 엔티티는 즉시 로드되지 아니하며, 초기 쿼리가 불필요하게 많은 데이터를 가져오지 않도록 하여 초기 로딩 성능을 최적화 하였다.

 

여기서 주목할 것을 지연로딩과 엔티티그래프와의 조합이다

 

지연 로딩은 엔티티 그래프와 함께 사용할 때 더욱 강력한 효과를 발휘한다. 엔티티 그래프를 통해 특정 시점에 필요한 연관된 엔티티들을 명시적으로 로드할 수 있기 때문이다. 앞선 레퍼지토리 코드를 다시 한 번 살펴보자면,

 

 

findDetailById 메서드를 호출할 때 Product.detail 엔티티 그래프를 사용하여 category, reviews, productOptions를 함께 로드한다. 이는 이 메서드가 호출될 때만 해당 연관된 엔티티들을 로드하게 하여, 지연 로딩의 이점과 함께 명시적 로딩의 이점을 모두 활용한다

 

 

- 마무리

 

지연 로딩과 엔티티 그래프를 적절히 조합하여 사용함으로써, 데이터베이스 쿼리를 최적화하고 성능을 향상시킬 수 있다. 이를 통해 전체 상품 조회, 카테고리 별 상품 조회, 상품 상세 페이지 조회 등 기능에서 발생하는 과도한 쿼리 문제를 효과적으로 해결할 수 있었고. 앞으로도 지속적인 최적화를 통해 사용자 경험을 개선하고, 최적화된 쇼핑몰 시스템을 완성할 것이다.