본문 바로가기
JPA

Querydsl 실무 활용편 (1)

by Heesu.lee 2021. 5. 2.

순수 JPA 와 Querydsl

MemberJpaRepository

@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {

    private final EntityManager em;
    private final JPAQueryFactory queryFactory;

    public void save(Member member) {
        em.persist(member);
    }

    public Optional<Member> findById(Long id) {
        Member member = em.find(Member.class, id);
        return Optional.ofNullable(member);
    }

    public List<Member> findAll() {
        return em.createQuery("select m from Member m", Member.class)
                .getResultList();
    }

    public List<Member> findAllQuerydsl() {
        return queryFactory
                .selectFrom(member)
                .fetch();
    }

    public List<Member> findByUsername(String username) {
        return em.createQuery("select m from Member m where m.username = :username", Member.class)
                .setParameter("username", username)
                .getResultList();
    }

    public List<Member> findByUsernameQuerydsl(String username) {
        return queryFactory
                .selectFrom(member)
                .where(member.username.eq(username))
                .fetch();
    }
}

스프링 데이터 JPA 가 아닌 순수 JPA 로 작성된 Member 용 레토지토리이다.

레포지토리를 작성하기 위해 EntityManager 와  JPAQueryFactory(Querydsl 사용) 를 주입 받는다.

 

주입 받은 EntityManager 와  JPAQueryFactory 를 사용하여 데이터를 관리할 수 있는 메서드들을 작성할 수 있다.

 

여기서 한가지 짚고 넘어가야할 부분은 JPAQueryFactory 를 주입 받는 방식이다.

JPAQueryFactory 를 Bean 으로 등록하여 사용, 생성자에서 주입 받는 EntityManager 를 넣어 생성하여 사용

 

// EntityManager 만 주입받고 JPAQueryFactory 는 생성 방식
@Repository
public class MemberJpaRepository {

    private final EntityManager em;
    private final JPAQueryFactory queryFactory;

    public MemberJpaRepository(EntityManager entityManager) {
        this.em = entityManager;
        this.queryFactory = new JPAQueryFactory(entityManager);
    }
    
    ...
 }
 
 // JPAQueryFactory Bean 으로 등록하여 모두 주입 받는 방식
@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {

    private final EntityManager em;
    private final JPAQueryFactory queryFactory;
 	
    ...
}

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

 

JPAQueryFactory 를 Bean 으로 등록 시 동시성 문제에 대해 생각해볼 수 있겠지만, 이는 고려하지 않아도 괜찮다.

그 이유로는 스프링이 주입해주는 EntityManager 는 런타임 시에 필요한 EntityManager를 사용할 수 있도록 라우팅 해주는 프록시  EntityManager 이다. 해당 프록시는 실제 사용 시점에 트랜잭션 단위로 실제 EntityManager(영속성 컨텍스트)를 할당해준다.

 

동적 쿼리와 성능 최적화 조회 - Builder 사용

조회 최적화 용도 DTO 추가 - MemberTeamDto

@Data
public class MemberTeamDto {

    private Long memberId;
    private String username;
    private int age;
    private Long teamId;
    private String teamName;

    @QueryProjection
    public MemberTeamDto(Long memberId, String username, int age, Long teamId, String teamName) {
        this.memberId = memberId;
        this.username = username;
        this.age = age;
        this.teamId = teamId;
        this.teamName = teamName;
    }
}

멤버 검색 조건 DTO

@Data
public class MemberSearchCondition {
    //회원명, 팀명, 나이(ageGoe, ageLoe)
    private String username;
    private String teamName;
    private Integer ageGoe;
    private Integer ageLoe;
}

Builder를 활용한 Repository 메소드 생성

public List<MemberTeamDto> searchByBuilder(MemberSearchCondition condition) {
    BooleanBuilder builder = new BooleanBuilder();

    if (StringUtils.hasText(condition.getUsername())) {
        builder.and(member.username.eq(condition.getUsername()));
    }
    if (StringUtils.hasText(condition.getTeamName())) {
        builder.and(team.name.eq(condition.getTeamName()));
    }
    if (ObjectUtils.isEmpty(condition.getAgeGoe())) {
        builder.and(member.age.goe(condition.getAgeGoe()));
    }
    if (ObjectUtils.isEmpty(condition.getAgeLoe())) {
        builder.and(member.age.loe(condition.getAgeLoe()));
    }

    return queryFactory
            .select(new QMemberTeamDto(
                    member.id,
                    member.username,
                    member.age,
                    team.id,
                    team.name))
            .from(member)
            .leftJoin(member.team, team)
            .where(builder)
            .fetch();
}

검색 조건 조회 예제 테스트

@DisplayName("Builder 를 활용한 조회 테스트")
@Test
void searchTest() {
    // given
    setUp();
    
    MemberSearchCondition condition = new MemberSearchCondition();
    condition.setAgeGoe(35);
    condition.setAgeLoe(40);
    condition.setTeamName("teamB");

    // when
    List<MemberTeamDto> result = memberJpaRepository.searchByBuilder(condition);

    // then
    assertThat(result).isNotNull();
    assertThat(result).extracting("username").containsExactly("member3", "member4");
}

앞서 살펴본 Builder 방식을 활용하여 동적 쿼리를 생성하는 예제를 작성해보았다.

BooleanBuilder 방식 역시 동적 쿼리에 대한 좋은 해결책을 제시해주지만,

아무래도 if 조건절이 해당 개수에 따라 계속 붙는게 불편하다.

 

추가로 만약 모든 조건이 없는 경우 모든 데이터를 조회하게 되는데,

이는 데이터 양이 많으면 많을 수록 위험할 수 있다. (조회 개수가 만건 억단위인 경우)

따라서 아무 조건이 없는 경우를 대비하여 기본 조건을 걸어준다거나, LIMIT, 페이징을 함께 사용하는 것이 좋다.

 

동적 쿼리와 성능 최적화 조회 - WHERE 절 파라미터 사용

WHERE 절 파라미터를 활용한 조회 로직 작성

public List<MemberTeamDto> search(MemberSearchCondition condition) {
    return queryFactory
            .select(new QMemberTeamDto(
                    member.id,
                    member.username,
                    member.age,
                    team.id,
                    team.name))
            .from(member)
            .leftJoin(member.team, team)
            .where( usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe()))
            .fetch();
}

private BooleanExpression ageLoe(Integer age) {
    return ObjectUtils.isEmpty(age) ? null : member.age.goe(age);
}

private BooleanExpression ageGoe(Integer age) {
    return ObjectUtils.isEmpty(age) ? null : member.age.goe(age);
}

private BooleanExpression usernameEq(String username) {
    return StringUtils.hasText(username) ? member.username.eq(username) : null;
}

private BooleanExpression teamNameEq(String teamName) {
    return StringUtils.hasText(teamName) ? team.name.eq(teamName) : null;
}

검색 조건 조회 예제 테스트

@DisplayName("WHERE 절 파라미터를 활용한 동적 쿼리 조회")
@Test
void searchTest() {
    // given
    setUp();
    MemberSearchCondition condition = new MemberSearchCondition();
    condition.setAgeGoe(35);
    condition.setAgeLoe(40);
    condition.setTeamName("teamB");

    // when
    List<MemberTeamDto> result = memberJpaRepository.search(condition);

    // then
    assertThat(result).isNotNull();
    assertThat(result).extracting("username").containsExactly("member4");
}

 

WHERE 절에 조건 파라미터를 활용한 동적 쿼리 작성의 경우 메서드를 따로 추출하여 생성한다는 점이 다소 불편할 수 있겠지만, 앞서 살펴본 Builder 방식 보다 훨씬 가독성 높은 코드를 제공한다. 이 뿐만 아니라 메서드 추출함으로써 코드의 의도를 더 명확히 알 수 있게 해준다.

 

이렇게 분리된 메서드들은 차후 동일한 레포지토리 계층 여러 메서드에서 재사용될 가능성이 높기 때문에 코드의 중복을 방지하고 재사용성을 높이는데 도움을 준다.

 

Spring Data JPA 와 Querydsl

Spring Data JPA 로 작성한 MemberRepostory

public interface MemberRepository extends JpaRepository<Member, Long> {

    Optional<Member> findByUsername(String username);
}

Spring Data JPA Test Code 

@SpringBootTest
@Transactional
class MemberRepositoryTest {

    @Autowired EntityManager em;
    @Autowired MemberRepository memberRepository;

    @DisplayName("Spring Data JPA 레포지토리 기본 테스트")
    @Test
    void basicTest() {
        // given
        Member member = new Member("member1", 10);

        // when
        Member saved = memberRepository.save(member);

        // then
        Member findById = memberRepository.findById(saved.getId())
                .orElseThrow(NoSuchElementException::new);
        assertThat(findById).isEqualTo(member);

        List<Member> members = memberRepository.findAll();
        assertThat(members).containsExactly(member);

        Member findByUsername = memberRepository.findByUsername(saved.getUsername())
                .orElseThrow(NoSuchElementException::new);
        assertThat(findByUsername).isEqualTo(member);
    }
}

Spring Data JPA 를 활용한 Repository 의 경우 순수 JPA 와 달리 직접 EntityManager 를 사용하여 메서드를 만들지 않는다.

여기선 Spring Data JPA 가 추상화한 Repository 인터페이스를 상속 받음으로써 간단히 레포지토리를 만들 수 있다.

Spring Data JPA 에서 제공하는 JpaRepository 는 Spring Data 의 여러 Interface 들을 상속 받기 때문에,

해당 레포지토리를 상속 받음으로써 이미 다양한 CRUD 에 대한 메서드들을 제공해준다. - 참고

 

추가적인 메서드의 경우 @Query 어노테이션이나 쿼리 메서드를 통해서 정의해줄 수 있다. 

해당 인터페이스의 정의된 메서드들의 구현체는 Spring Data JPA 에서 제공해준다. (굉장히 편리함)

 

그렇지만, JpaRepository 를 상속 받은 레포지토리는 기존 순수 JPA 와 달리 Querydsl 을 사용한 메서드를 만들 수 없다.

이런 상황에서 사용자 정의 인터페이스를 추가적으로 생성하고 구현하도록 해주어야 한다.

 

사용자 정의 Repository 구성

Querydsl 메서드를 사용하기 위한 사용자 정의 인터페이스 구성

사용자 정의 인터페이스 작성

public interface MemberRepositorySupport {

    List<MemberTeamDto> search(MemberSearchCondition condition);
}

사용자 정의 인터페이스 구현

public class MemberRepositorySupportImpl implements MemberRepositorySupport {

    private final JPAQueryFactory queryFactory;

    public MemberRepositorySupportImpl(EntityManager entityManager) {
        this.queryFactory = new JPAQueryFactory(entityManager);
    }

    @Override
    public List<MemberTeamDto> search(MemberSearchCondition condition) {
        return queryFactory
                .select(new QMemberTeamDto(
                        member.id,
                        member.username,
                        member.age,
                        team.id,
                        team.name))
                .from(member)
                .leftJoin(member.team, team)
                .where( usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()))
                .fetch();
    }

    private BooleanExpression ageLoe(Integer age) {
        return ObjectUtils.isEmpty(age) ? null : member.age.goe(age);
    }

    private BooleanExpression ageGoe(Integer age) {
        return ObjectUtils.isEmpty(age) ? null : member.age.goe(age);
    }

    private BooleanExpression usernameEq(String username) {
        return StringUtils.hasText(username) ? member.username.eq(username) : null;
    }

    private BooleanExpression teamNameEq(String teamName) {
        return StringUtils.hasText(teamName) ? team.name.eq(teamName) : null;
    }
}

기존 Spring Data Repository 에 사용자 정의 인터페이스 상속

public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositorySupport {

    Optional<Member> findByUsername(String username);
}

사용자 정의 인터페이스 테스트

@DisplayName("사용자 정의 인터페이스 테스트")
@Test
void searchTest() {
    // given
    setUp();
    MemberSearchCondition condition = new MemberSearchCondition();
    condition.setAgeGoe(35);
    condition.setAgeLoe(40);
    condition.setTeamName("teamB");

    // when
    List<MemberTeamDto> members = memberRepository.search(condition);

    // then
    assertThat(members).isNotNull();
    assertThat(members).extracting("username").containsExactly("member4");
}

private void setUp() {
    Team teamA = new Team("teamA");
    Team teamB = new Team("teamB");
    em.persist(teamA);
    em.persist(teamB);

    Member member1 = new Member("member1", 10, teamA);
    Member member2 = new Member("member2", 20, teamA);
    Member member3 = new Member("member3", 30, teamB);
    Member member4 = new Member("member4", 40, teamB);
    em.persist(member1);
    em.persist(member2);
    em.persist(member3);
    em.persist(member4);
}

참고로 setUp 메서드 같은 경우 BeforeEach 메서드로 뺄 수 있음을 알아줬으면 좋겠다. (현재 중요한 부분은 아니라 따로 빼둠)

 

이번 게시글에선 순수 JPA 와 Spring Data JPA 로 각각 어떤 식으로 레포지토리를 구성할 수 있는지,

그리고 각 스펙에서 Querydsl 를 어떤 식으로 활용할 수 있는지를 알아보았다.

다음 게시글에선 좀 더 상세한 기술 지원들에 대해 알아볼 수 있도록 하겠다.

 

참고

  • 해당 게시글은 김영한님의 "실전! Querydsl" 을 참고하여 작성하였습니다.
  • 더 자세한 내용은 해당 강좌를 통해 확인하시길 적극 권장드립니다.

'JPA' 카테고리의 다른 글

Querydsl 실무 활용편 (2)  (0) 2021.05.03
Querydsl 중급 문법  (0) 2021.04.28
Querydsl 기본 문법  (0) 2021.04.26
Querydsl 사용 개요  (0) 2021.04.25
쿼리 메소드 기능  (0) 2021.01.26

댓글