만들어보기/Spring

SpringBoot + QueryDSL 적용 살펴보기

다섯자두 2024. 11. 25. 20:47

QueryDslConfig.java

package com.lettrip.lettripbackend.configuration;


import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class QueryDslConfig {
    @PersistenceContext
    private EntityManager entityManager;
    public QueryDslConfig() {
    }

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(this.entityManager);
    }
}
EntityManager : JPA에서 Entity와 상호작용하는 인터페이스, Spring에서는 하나의 영속성 컨텍스트를 여러 EntityManager가 참조한다. 
JPAQueryFactory : JPA를 사용해 동적 쿼리를 작성하고 실행하기 위한 핵심 클래스이다. EntityManager를 기반으로 QueryDSL 쿼리를 실행할 수 있는 인터페이스를 제공한다.

 

기존 Repository 구조

기존 프로젝트 구조는 아래와 같다.

 

 

JPA, JPA Specification, QueryDSL을 전부 사용하고 있음을 알 수 있다.

위에서 나열된 순서대로 도입하였고, 사실 QueryDSL를 사용해야겠다고 마음 먹었을 때 Specification 을 사용해서 구현했던 내용을 QueryDSL로 옮겼어야 했다. 

하지만 나는 졸작 제출 기한에 쫓기던 4학년이었고 일단 기능에 문제가 없으니 나중에 옮기지 뭐 ~ 하고 일단 셋 다 사용해서 구현을 해놓았다.

 

그리고 ... 지금 그 나중이 되었다 ...

 

Repository 구조 다시 잡기

우선 QueryDSL을 사용할 때, JPAQueryFactory를 사용하는 QueryRepository를 JPARepository와 별개로 두어 사용했다.

그래서 Service 레이어에서 두 개의 Repository 의존성을 가지게 되었다. 

 

MissionService.java

package com.lettrip.lettripbackend.service;

import ...

@RequiredArgsConstructor
@Service
public class MissionService {
    public static final int QR_MISSION_POINT = 500;
    private final MissionRepository missionRepository;
    private final MissionQueryRepository missionQueryRepository;
    private final UserService userService;
    private final PlaceService placeService;

    @Transactional
    public ApiResponse saveMission(MissionDto.Request missionDto) {
        // 1. 미션 포인트 저장할 사용자 email로 찾기
        User user = userService.findUserByEmail(missionDto.getEmail());
        // 2. 미션 장소 찾기
        Place place = placeService.findPlaceByLocationPoint(
                placeService.getFormatLocationPoint(
                        String.valueOf(missionDto.getXpoint()),
                        String.valueOf(missionDto.getYpoint())
                )
        );
        // 3. 미션 저장
        missionRepository.save(
                Mission.builder()
                        .user(user)
                        .place(place)
                        .missionType(MissionType.QR_MISSION)
                        .accomplishedDate(missionDto.getAccomplishedDate())
                        .build()
        );
        // 4. 포인트 반영
        user.addPoint(QR_MISSION_POINT);

        return new ApiResponse(true,"미션 포인트 적립이 완료되었습니다.");
    }

    /*
    1. 미션 다득점자 (각 미션 별로)
    2. 음식점 & 카페 다방문자
    3. 다양한 지역을 여행다닌 사람
     */

    public List<RankingDto.Response> getRankingList(RankingDto.Request request) {
        RankingType rankingType = RankingType.valueOf(request.getRankingType());
        switch (rankingType) {
            case QR_MISSION -> {return getMissionRanking(MissionType.QR_MISSION, request);}
            case RANDOM_MISSION -> {return getMissionRanking(MissionType.RANDOM_MISSION, request);}
            case REVIEW_RESTAURANT -> {return getFoodPlaceVisitRanking(PlaceCategory.FD6,request);}
            case REVIEW_CAFE -> {return getFoodPlaceVisitRanking(PlaceCategory.CE7,request);}
            case TRAVEL_CITY -> {return getMostCityVisitRanking(request);}
            default -> {return new ArrayList<>();}
            }
    }

    public  List<RankingDto.Response> getMissionRanking(MissionType missionType, RankingDto.Request request) {
        return missionQueryRepository.getMissionCountByUser(missionType,request.getFrom(),request.getTo());
    }

    public List<RankingDto.Response> getFoodPlaceVisitRanking(PlaceCategory placeCategory, RankingDto.Request request) {
        return missionQueryRepository.getFoodReviewCountByUser(placeCategory,request.getFrom(),request.getTo());
    }

    public List<RankingDto.Response> getMostCityVisitRanking(RankingDto.Request request) {
        return missionQueryRepository.getMostCityCountByUser(request.getFrom(),request.getTo());
    }



}
private final MissionRepository missionRepository;
private final MissionQueryRepository missionQueryRepository;

두 개의 Repository 의존성을 가지고 있음을 확인할 수 있다.

 

MissionRepository

package com.lettrip.lettripbackend.repository;

import com.lettrip.lettripbackend.domain.mission.Mission;
import jakarta.annotation.Nullable;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;

public interface MissionRepository extends JpaRepository<Mission, Long>, JpaSpecificationExecutor<Mission> {
    Page<Mission> findAll(@Nullable Specification<Mission> spec, Pageable pageable);
}

보다싶이 MissionRepository에서는 또 Specification을 사용하고 있다.

Specification을 정의하는 파일이 또 필요하므로 MissionSpecification.java 파일이 존재한다.

하나의 도메인에 대해서 JPA, JPA Specification, QueryDSL 각각을 위한 Repository 파일이 전부 존재하는 구조이다. 

 

 

  1. 의존성 관리
    • Service단에서 Repository단으로의 하나의 의존성만을 갖도록 한다.
  1. 복잡한 설계 및 중복된 기술 선택 문제
    • SpecificationQueryDSL은 중복되므로 두 기술 중 QueryDSL을 선택하여 복잡한 쿼리를 작성한다.

 

변경된 Repository 구조

1. __Repository (interface)

JPARepository와 Custom Interface를 상속한다.

Service단에서는 이 파일에 대한 의존성만을 가진다.

 

2. __RepositoryCustom (interface)

QueryDSL로 사용할 메서드를 선언한다.

 

3. __RepositoryImpl (class)

Custom Interface에서 선언한 메서드를 구현한다.

 

Spring Data JPA는 다음 규칙을 사용하여 Custom Repository를 매핑한다.

1. Custom Interface와 Custom Implementation의 이름이 일치해야 한다. 즉, __RepositoryCustom이라는 인터페이스가 있다면, 해당 구현체의 이름은 반드시 __RepositoryImpl이어야 한다.
2. Spring은 __Repository를 생성할 때, JpaRepository의 메서드는 기본 구현체로, __RepositoryCustom의 메서드는 __RepositoryImpl 구현체로 연결하여 단일 프록시 객체를 제공한다.