Springboot

[Springboot] QueryDSL을 활용한 물물교환 경매 시스템 개발기

KJihun 2023. 8. 8. 23:00
728x90

프로젝트 배경

물물교환 프로젝트를 진행하면서 경매 시스템을 추가하게 되었다.

경매 시스템에서는 하한가 설정이 필수적인데, 팀원들과 많은 고민 끝에 등록된 물건의 가격을 랜덤한 유저들이 평가하게 하여 그 평균값을 하한가로 적용하는 방식을 선택하였다.

이 과정에서 복잡한 쿼리가 필요했고, 처음으로 QueryDSL을 도입하게 되었다.

QueryDSL이란?

QueryDSL은 Java 기반의 타입 안전한 쿼리 작성을 위한 프레임워크이다.

기존 JPQL이나 Native Query의 단점을 보완하여 다음과 같은 장점을 제공한다.

QueryDSL의 주요 장점

  1. 타입 안전성: 컴파일 타임에 쿼리 오류를 잡아낼 수 있다
  2. 가독성: SQL과 유사한 문법으로 직관적인 쿼리 작성이 가능하다
  3. IDE 지원: 자동 완성과 리팩토링 기능을 완벽하게 지원한다
  4. 동적 쿼리: 조건에 따른 동적 쿼리 작성이 간편하다
  5. 재사용성: 쿼리 로직을 메서드로 분리하여 재사용할 수 있다

 

프로젝트 설정

Gradle 설정

dependencies {
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}

def querydslSrcDir = 'src/main/generated'
clean {
    delete file(querydslSrcDir)
}
tasks.withType(JavaCompile) {
    options.generatedSourceOutputDirectory = file(querydslSrcDir)
}

QueryDSL 설정

@Configuration
public class QuerydslConfig {

    @Bean
    JPAQueryFactory jpaQueryFactory(EntityManager em) {
        return new JPAQueryFactory(em);
    }
}

 

Repository 구조

// 메인 Repository 인터페이스
public interface RatingRepository extends JpaRepository<Rating, Long>, RatingRepositoryCustom {
    // JpaRepository와 커스텀 인터페이스를 동시에 상속
}

// 커스텀 쿼리 메서드 선언
public interface RatingRepositoryCustom {
    Rating findRandomRatingWithCountLessThanOrEqual7(Set<Long> excludeIds);
}

// 실제 QueryDSL 구현체
@RequiredArgsConstructor
public class RatingRepositoryCustomImpl implements RatingRepositoryCustom {
    private final JPAQueryFactory queryFactory;
    
    @Override
    public Rating findRandomRatingWithCountLessThanOrEqual7(Set<Long> excludeIds) {
        QRating qRating = QRating.rating;
        
        List<Long> ids = queryFactory.select(qRating.ratingId)
                .from(qRating)
                .where(qRating.ratingCount.loe(7)
                    .and(qRating.ratingId.notIn(excludeIds)))
                .orderBy(qRating.ratingCount.asc())
                .fetch();

        if (ids.isEmpty()) {
            return null;
        }

        Long randomId = getRandomId(ids);

        return queryFactory.selectFrom(qRating)
                .where(qRating.ratingId.eq(randomId))
                .fetchOne();
    }

    private Long getRandomId(List<Long> ids) {
        int randomIndex = ThreadLocalRandom.current().nextInt(ids.size());
        return ids.get(ids);
    }
}

 

구조 설명

  • RatingRepository: JpaRepository와 RatingRepositoryCustom을 다중 상속
  • RatingRepositoryCustom: 커스텀 쿼리 메서드의 선언부 역할
  • RatingRepositoryCustomImpl: 실제 QueryDSL을 이용한 구현체

중복 제거 로직 구현

사용자가 이미 평가한 물품을 다시 보여주지 않도록 중복 제거 로직을 구현했다.

User와 Rating 테이블의 다대다 관계를 처리하기 위해 중간 테이블을 활용하였다.

Service Layer

@Transactional
public ApiResponse<RatingResponseDto> randomRatingGoods(Long userId) {
    // 사용자가 이미 평가한 물품 ID 조회
    Set<Long> userRatedGoods = userRatingRelationRepository.findUserCheckedGoodsByUserId(userId);
    
    // 중복 제외하고 랜덤 물품 조회
    Rating rating = ratingRepository.findRandomRatingWithCountLessThanOrEqual7(userRatedGoods);
    User user = userHelper.getUser(userId);

    Goods goods = rating.getGoods();
    Image image = rating.getImage();
    
    // 평가 관계 저장
    userRatingRelationRepository.save(new UserRatingRelation(user, rating));

    return new ApiResponse<>(true, new RatingResponseDto(goods, image.getImageUrl()), null);
}

중복 조회 Repository

@RequiredArgsConstructor
public class UserRatingRepositoryCustomImpl implements UserRatingRepositoryCustom {
    private final JPAQueryFactory queryFactory;

    @Override
    public Set<Long> findUserCheckedGoodsByUserId(Long userId) {
        QUserRatingRelation qUserRatingRelation = QUserRatingRelation.userRatingRelation;

        // Set을 사용하여 빠른 조회 성능 확보
        Set<Long> userCheckedGoods = new HashSet<>(
            queryFactory.select(qUserRatingRelation.rating.ratingId)
                .from(qUserRatingRelation)
                .where(qUserRatingRelation.user.userId.eq(userId))
                .fetch()
        );

        if (userCheckedGoods.isEmpty()) {
            userCheckedGoods.add(0L); // 첫 평가시 NullException 방지
        }

        return userCheckedGoods;
    }
}

QueryDSL 사용 경험과 느낀 점

처음 사용하며 느낀 점

  1. 기존 SQL 지식이 있어서 문법이 비슷하다고 생각했지만, 실제로는 미묘한 차이점들이 있어 초기에 헷갈렸다.
  2. 컴파일 타임에 오류를 잡을 수 있어서 런타임 오류를 크게 줄일 수 있었다.
  3. 복잡한 쿼리도 메서드 체이닝으로 직관적으로 작성할 수 있었다.

성능 최적화 포인트

  • Set 자료구조 활용: 중복 확인 시 O(1) 시간복잡도를 갖는 Set을 사용하여 조회 성능을 향상시킴
  • 정렬 최적화: orderBy(qRating.ratingCount.asc())를 통해 평가 횟수가 적은 물품을 우선 선택하도록 구현

 

QueryDSL의 본질적 특성

QueryDSL은 JPA 엔티티 기반으로 동작하는 프레임워크다.

JPA가 객체-관계 매핑을 통해 데이터베이스와 상호작용하는 것처럼, QueryDSL도 결국 SQL 쿼리를 생성하기 위한 Java 코드 기반 도구다.

JPA가 복잡한 쿼리나 특정 검색 조건 표현에 어려움이 있을 때 QueryDSL이 이를 보완해주지만 완벽하지는 않았다.

실제 프로젝트를 진행하면서 다음과 같은 상황에서는 네이티브 SQL이 더 적합하다는 것을 깨달았다

  1. 데이터베이스 특화 함수 사용: RAND(), DATE_FORMAT() 등
  2. 복잡한 서브쿼리: 여러 단계의 중첩된 쿼리
  3. 성능 최적화: 특정 데이터베이스의 최적화 기능 활용
  4. 레거시 쿼리: 이미 검증된 복잡한 SQL 쿼리

복잡한 쿼리 사례: 네이티브 SQL 활용

프로젝트를 진행하면서 "사용자가 평가하지 않은 상품 중에서 자신이 등록하지 않은 상품을 랜덤으로 선택"하여야 하는 쿼리문이 필요했다.

QueryDSL로 구현하려면 매우 복잡해지거나 여러 쿼리로 나누어야 했기에, 네이티브 쿼리로 한 번에 처리하였다.

 
@Query(value = "select g1.* " +
        "from goods g1 " +
        "where g1.goods_id not in " +
        "(select g2.goods_id " +
        "from goods g2 " +
        "inner join rating_goods rg on rg.goods_id = g2.goods_id " +
        "inner join rating r on r.rating_goods_id = rg.rating_goods_id " +
        "inner join user_rating_relation urr on urr.rating_id = r.rating_id " +
        "inner join user u on u.user_id = urr.user_id " +
        "where u.user_id = :#{#targetUser.userId}) " +
        "and g1.user_id <> :#{#targetUser.userId} " +
        "order by rand() limit 1", 
        nativeQuery = true)
Goods findRandomGoods(@Param("targetUser") User user);

 

어떤 도구가 최고인가 보다는 언제 어떤 도구를 사용할 것인가가 더 중요하다는 것을 깨달았다

  • 간단한 쿼리: JPA Repository 메서드
  • 중간 복잡도: QueryDSL
  • 고도로 복잡한 쿼리: 네이티브 SQL

마무리

QueryDSL을 도입하면서 타입 안전성과 가독성의 장점을 경험했지만, 동시에 모든 상황에 만능이 아니라는 것도 알게되었다.

앞으로는 각 도구의 장단점을 이해하고 상황에 맞는 최적의 선택하여 사용할 수 있도록 노력해야겠다.

'Springboot' 카테고리의 다른 글

[Springboot] Client-Server 구조 정리  (0) 2025.03.26
[Springboot] JWT 0.15.2  (0) 2024.06.22
[SpringBoot] Redis Caching  (0) 2023.08.06
[Springboot] S3에 이미지 올리기  (0) 2023.08.01
[Springboot] UnsatisfiedDependencyException  (0) 2023.08.01