컴공생의 발자취

[JPA] QueryDSL 조회 속도 개선 비교 (전체 성능 51.39% 향상) 본문

💚 Spring

[JPA] QueryDSL 조회 속도 개선 비교 (전체 성능 51.39% 향상)

MNY 2024. 9. 25. 16:30
728x90
반응형

 

프로젝트를 진행하면서 기한을 맞추기 위해 초기의 코드들은 전부 JPA를 사용했다. 근데 기한에 맞추기 위한 코딩을 하다보니 하나의 쿼리를 조회하면 N+1문제로 무수히 많은 쿼리가 생성되었다. 이를 개선하기 위해 팀원들과 의논 후 기간이 한정되어 있기에 조회부분만 QueryDSL을 도입했다. 그 결과 전체 성능이 51.39% 향상되었다. 성능 향상 후 비교했던 기능은 상품 조회, 마이페이지, 채팅방 조회 이렇게 3개의 기능이었다. 해당 포스팅은 어떤 점에서 문제가 발생했으며 최적화 전후의 성능 차이를 비교한다.

 

성능 문제점

초기의 JPA만 사용한 코드는 빠르게 진행하는 데에 도움이 되었지만, 성능 저하를 발생시켰다. 특히 상품 전체 조회, 마이페이지 내 상품 조회, 채팅방 조회 등과 같이 조회 부분에서 두드러지게 성능 저하가 발생했다.


주요 문제점
:

  • N+1 문제:다수의 연관 데이터를 처리할 때 JPA가 불필요한 쿼리 발생
  • 여러 쿼리 호출: 한 번의 요청에 여러 데이터베이스 호출로 인해 불필요한 쿼리 발생
  • JPA의 기본 동작: 개발 편의성은 높으나, 성능 최적화시 직접 컨트롤이 어려운 문제

 

초기 JPA 사용 분석

성능 문제의 주요 원인은 JPA의 기본 쿼리 생성 방식이었다. JPA는 편리하게 데이터를 처리할 수 있지만, 쿼리 최적화가 부족할 때 성능이 저하될 수 있다. 특히 데이터가 늘어나면 응답 시간이 길어졌다. 주요 문제점은 앞서 정리한 것처럼 N+1 문제,  여러 쿼리 호출, JPA 기본 동작의 한계였다.

 

이러한 문제를 해결하기 위해 QueryDSL을 도입하여 쿼리 최적화를 적용했다. QueryDSL을 통해 복잡한 쿼리를 하나의 호출로 처리할 수 있었고, 불필요한 쿼리 호출을 줄여 성능을 크게 향상시켰다.

 

초기 코드 (JPA 사용)

1. 채팅방 조회

    @Transactional(readOnly = true)
    public List<ChatRoomResponseDto> getChatRoom(Long id) {
        List<ChatRoom> chatRooms = chatRoomRepository.findBySellerOrBuyer(id, id);

        return chatRooms.stream()
            .map( (chatRoom) -> {
                Long userId;

                if (chatRoom.getSeller().equals(id)) {
                    userId = chatRoom.getBuyer();
                } else {
                    userId = chatRoom.getSeller();
                }

                User user = userService.findById(userId);

                return new ChatRoomResponseDto(
                    chatRoom.getRoomId(),
                    user.getUsername(),
                    user.getProfileImage()
                );
            }).toList();
    }

 

문제점:

  • N+1 문제: chatRoomRepository.findBySellerOrBuyer(id, id)를 통해 채팅방 목록을 가져온 후, 각 채팅방에 대한 사용자 정보를 가져오는 userService.findById(userId)가 반복 호출되어 각 채팅방마다 추가적인 쿼리가 발생한다. 이로 인해 채팅방 개수만큼 데이터베이스 호출이 이루어지며 성능이 크게 저하되고 있었다.

 

2. 마이페이지 내 상품 조회

    @Transactional(readOnly = true)
    public Page<ProductResponseDto> getMyProductList(Long id, int page) {

        int size = 6;
        Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
        Page<Product> productPage = productRepository.findByUserId(id, pageable);

        return productPage.map((product) -> {

            List<ImageProduct> images = imageProductRepository.findByProductIdOrderByCreatedAtAsc(
                product.getId());

            ImageProduct firstImage = images.isEmpty() ? null : images.get(0);

            if (firstImage == null) {
                throw new CustomException(ErrorCode.NO_REPRESENTATIVE_IMAGE_FOUND);
            }

            Payment payment = paymentRepository.findByProductId(product.getId());
            boolean paymentState = payment != null && payment.getState() == State.COMPLATE;

            return new ProductResponseDto(product.getId(), product.getPrice(), product.getTitle(),
                s3Provider.getS3Url(firstImage.getFilePath()), paymentState);
        });
    }

 

문제점:

  • 여러 쿼리 호출: productRepository.findByUserId(id, pageable)로 상품 목록을 가져온 후, 각 상품에 대해 imageProductRepository.findByProductIdOrderByCreatedAtAsc와 paymentRepository.findByProductId를 각각 호출하면서 하나의 조회로 여러 쿼리가 실행됐다. 이로 인해 상품 수가 많아질수록 데이터베이스 호출이 급격히 증가했다.
  • 성능 저하: 각 상품에 대해 이미지와 결제 정보를 개별적으로 조회하는 방식은 많은 데이터베이스 트랜잭션을 유발해 성능을 떨어뜨렸다.

 

3. 전체 상품 조회

    @Transactional(readOnly = true)
    public Page<ProductResponseDto> getProductPage(Pageable pageable) {

        Page<Product> page = productRepository.findAll(pageable);

        if (page.isEmpty()) {
            throw new CustomException(ErrorCode.INVALID_REQUEST);
        }

        return getProductImageResponseDtoPage(page);
    }
    
    private Page<ProductResponseDto> getProductImageResponseDtoPage(Page<Product> page) {

        return page.map((product) -> {

            List<ImageProduct> images = imageProductRepository.findByProductIdOrderByCreatedAtAsc(
                product.getId());

            ImageProduct firstImage = images.isEmpty() ? null : images.get(0);

            if (firstImage == null) {
                throw new CustomException(ErrorCode.NO_REPRESENTATIVE_IMAGE_FOUND);
            }

            Payment payment = paymentRepository.findByProductId(product.getId());
            boolean paymentState = payment != null && payment.getState() == State.COMPLATE;

            return new ProductResponseDto(product.getId(), product.getPrice(), product.getTitle(),
                s3Provider.getS3Url(firstImage.getFilePath()), paymentState);
        });
    }

 

문제점:

  • N+1 문제:
    • getProductImageResponseDtoPage 메서드에서 각 상품에 대한 이미지를 조회할 때 imageProductRepository.findByProductIdOrderByCreatedAtAsc를 사용하고 있어, 각 상품마다 별도의 쿼리가 발생해 성능을 저하시켰다.
    • 결제 정보를 paymentRepository.findByProductId로 개별적으로 조회하기 때문에 동일한 문제가 발생했다.
  • 다중 쿼리 호출:
    • 하나의 상품 정보를 반환할 때, 이미지와 결제 정보를 각각 별도의 쿼리로 조회하고 있어서 데이터베이스에 불필요한 부하가 생기며 성능을 저하시켰다.
 

 

QueryDSL을 활용한 최적화

ex: QueryDSL을 도입하여 쿼리 구조를 개선하고, 필요한 데이터만 정확하게 조회하도록 최적화했습니다. 이를 통해 불필요한 쿼리 호출을 줄이고, 데이터를 더욱 효율적으로 처리할 수 있었습니다.

 

최적화 후 코드 (QueryDSL 사용)

QueryDSL을 사용하면서, 필요한 조건에 맞는 데이터만 정확하게 불러오고, Join 구조를 최적화함으로써 응답 시간을 크게 단축할 수 있었다.

 

1. 채팅방 조회

    @Transactional(readOnly = true)
    public List<ChatRoomResponseDto> getChatRoom(Long id) {
        return chatRoomRepository.findByChatRoomWithUserInfo(id);
    }

    @Override
    public List<ChatRoomResponseDto> findByChatRoomWithUserInfo(Long id) {

        QChatRoom chatRoom = QChatRoom.chatRoom;
        QUser buyer = QUser.user;
        QUser seller = new QUser("seller");

        return jpaQueryFactory.select(new QChatRoomResponseDto(
                        chatRoom.roomId,
                        Expressions.stringTemplate("CASE WHEN {0} = {1} THEN {2} ELSE {3} END",
                                chatRoom.seller, id, buyer.username, seller.username),
                        Expressions.stringTemplate("CASE WHEN {0} = {1} THEN {2} ELSE {3} END",
                                chatRoom.seller, id, buyer.profileImage, seller.profileImage)
                ))
                .from(chatRoom)
                .leftJoin(buyer).on(chatRoom.buyer.eq(buyer.id))
                .leftJoin(seller).on(chatRoom.seller.eq(seller.id))
                .where(chatRoom.seller.eq(id)
                        .or(chatRoom.buyer.eq(id)))
                .fetch();
    }

 

QueryDSL을 활용하여 사용자의 정보를 포함한 채팅방을 한 번의 쿼리로 조회하도록 변경했다. 이를 통해 불필요한 쿼리 호출을 줄이고, 응답 시간을 단축 시켰다.

 

최적화 효과:

  • N+! 문제를 해결하며, 단일 쿼리로 연관된 정보를 함께 가져옴으로써 데이터베이스의 부하를 감소시켰다.

 

2. 마이페이지 내 상품 조회

    @Transactional(readOnly = true)
    public Page<ProductResponseDto> getMyProductList(Long id, int page) {

        int size = 6;
        Pageable pageable = PageRequest.of(page, size, Sort.by("id").descending());

        return productRepository.findMyProductList(id, pageable);
    }
    
    @Override
    public Page<ProductResponseDto> findMyProductList(Long userId, Pageable pageable) {

        QProduct product = QProduct.product;
        QImageProduct imageProduct = QImageProduct.imageProduct;
        QPayment payment = QPayment.payment;

        List<ProductResponseDto> productList = jpaQueryFactory
                .select(new QProductResponseDto(
                        product.id,
                        product.price,
                        product.title,
                        JPAExpressions
                                .select(imageProduct.filePath)
                                .from(imageProduct)
                                .where(imageProduct.id.eq(
                                        JPAExpressions
                                                .select(imageProduct.id.min())
                                                .from(imageProduct)
                                                .where(imageProduct.product.id.eq(product.id))
                                )),
                        payment.state.when(State.COMPLATE).then(true).otherwise(false)
                )).from(product)
                .leftJoin(payment).on(payment.product.id.eq(product.id))
                .where(product.user.id.eq(userId))
                .orderBy(product.id.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        productList.forEach(this::validateProductImage);

        long total = jpaQueryFactory
                .select(product.id)
                .from(product)
                .where(product.user.id.eq(userId))
                .fetch()
                .size();

        return new PageImpl<>(productList, pageable, total);
    }

 

QueryDSL을 통해 이미지와 결제 정보를 동시에 조회할 수 있도록 변경했다. 서브쿼리를 활용하여 각 상품의 대표 이미지를 효율적으로 가져오는 방식으로 변경했다.

 

최적화 효과:

  • 불필요한 쿼리 호출을 줄이고, 상품과 연관된 데이터를 한 번의 쿼리로 처리하여 응답 속도가 향상되었다. 페이징 처리도 효율적으로 이루어졌다.

 

3. 전체 상품 조회

    @Transactional(readOnly = true)
    public Page<ProductResponseDto> getProductPage(Pageable pageable) {

        Page<ProductResponseDto> page = productRepository.findProducts(pageable);

        if (page.isEmpty()) {
            throw new CustomException(ErrorCode.INVALID_REQUEST);
        }

        return page;
    }
    
    @Override
    public Page<ProductResponseDto> findProducts(Pageable pageable) {

        QProduct product = QProduct.product;
        QImageProduct imageProduct  = QImageProduct.imageProduct;
        QPayment payment = QPayment.payment;

        List<OrderSpecifier<?>> orderSpecifiers = getOrderSpecifier(pageable.getSort(), product);

        List<ProductResponseDto> products = jpaQueryFactory
            .select(Projections.constructor(
                ProductResponseDto.class,
                product.id,
                product.price,
                product.title,
                    JPAExpressions
                            .select(imageProduct.filePath)
                            .from(imageProduct)
                            .where(imageProduct.id.eq(
                                    JPAExpressions
                                            .select(imageProduct.id.min())
                                            .from(imageProduct)
                                            .where(imageProduct.product.id.eq(product.id))
                            )),
                    payment.state.when(State.COMPLATE).then(true).otherwise(false)
            ))
            .from(product)
            .leftJoin(product.payment, payment)
            .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0]))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

        products.forEach(this::validateProductImage);

        long total = jpaQueryFactory.selectFrom(product)
            .fetch().size();

        return new PageImpl<>(products, pageable, total);

    }
 

QueryDSL을 사용하여 상품, 이미지, 결제 정보를 한 번의 쿼리로 효율적으로 조회하도록 변경했다.

 

최적화 효과:

  • 데이터베이스 호출 횟수를 크게 줄이면서도 필요한 모든 정보를 효과적으로 조회할 수 있게 됐다. 이는 전체 응답 시간을 단축시키고, 시스템의 안정성을 높였다.

 

성능 비교

성능 개선의 구체적인 결과를 제시하기 위해 최적화 전후의 응답 시간을 비교했다. 같은 서버의 환경에서 진행하기 위해 서버는 AWS의 t2.micro에서 동일하게 진행했다. DB 더미 데이터는 약 100개의 데이터를 각 테이블 (User & ChatRoom / Product & ImageProduct & Payment) 에 추가했다. 결과는 상품 조회, 마이페이지 내 상품 조회, 채팅방 조회에서 모두 성능이 크게 향상되었다.

 

1. 채팅방 조회

Before : 230ms

  • 처음 페이지 접속 시 : 230ms

 

  • 새로고침 1회 : 212ms

 

  • 새로고침 2회 : 230ms

 

 

After : 71ms / 25ms

  • 처음 페이지 접속 시 : 71ms

 

  • 새로고침 1회 : 24ms

 

  • 새로고침 2회 : 26ms

 

2. 마이페이지 내 상품 조회

Before : 224ms / 29.5ms

  • 처음 페이지 접속 시 : 245ms

 

  • 새로고침 1회 : 42ms

 

  • 새로고침 2회 : 57ms

 

After : 71ms / 16ms

  • 처음 페이지 접속 시 : 71ms

 

  • 새로고침 1회 : 22ms

 

  • 새로고침 2회 : 10ms

 

3. 전체 상품 조회

Before : 317ms / 260ms

  • 처음 페이지 접속 시 : 317ms

 

  • 새로고침 1회 : 260ms

 

  • 새로고침 2회 : 261ms

 

 

After : 243ms / 15ms

  • 처음 페이지 접속 시 : 243ms

 

  • 새로고침 1회 : 11ms

 

  • 새로고침 2회 : 22ms

 

전체 성능 비교

기능 최적화 전 응답 시간 최적화 후 응답 시간 성능 향상율
채팅방 조회  317ms / 260ms 243ms / 15ms 69.13%
마이페이지 내 상품 조회 224ms / 29.5ms 71ms / 16ms 70.00%
전체 상품 조회 230ms 71ms / 25ms 23.31%

각 기능에서 최대 51.39% 의 성능 개선을 이뤘다.

 

배운 점

이번 성능 최적화 작업을 통해 다음과 같은 교훈을 얻었다!

  • 쿼리 최적화의 중요성: 단순한 쿼리도 데이터 양이 많아질수록 성능에 큰 영향을 미친다는 것
  • QueryDSL의 유용성: 복잡한 쿼리 최적화에 QueryDSL이 매우 효과적인 도구라는 것
  • N+1 문제 해결: 조인 최적화를 통해 N+1 문제를 해결하고 성능을 크게 향상할 수 있다는 것

 

결론

JPA만을 사용해 비효율적이던 시스템을 QueryDSL을 활용해 최적화함으로써, 응답 시간이 대폭 개선되었다. 앞으로 캐싱과 데이터베이스 인덱싱 같은 추가적인 성능 최적화 작업도 고려할 예정이다.

728x90
반응형