본문 바로가기

QueryDSL

[QueryDSL] 순수 JPA와 QueryDSL

순수 JPA 리포지토리와 QueryDSL

순수 JPA 리포지토리

@Repository
public class MemberJpaRepository {

    private final EntityManager em;
    private final JPAQueryFactory queryFactory;

    public MemberJpaRepository(EntityManager em) {
        this.em = em;
        this.queryFactory = new JPAQueryFactory(em);
    }

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

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

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

    public List<Member> findAll_Querydsl() {
        return queryFactory
                .selectFrom(member)
                .from(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> findByUsername_Querydsl(String username) {
        return queryFactory
                .selectFrom(member)
                .where(member.username.eq(username))
                .fetch();
    }
}

 

JPAQueryFactory 스프링 빈 등록

 @Bean
 JPAQueryFactory jpaQueryFactory(EntityManager em) {
     return new JPAQueryFactory(em);
 }
@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {

    private final EntityManager em;
    private final JPAQueryFactory queryFactory;
    ...
}
  • 다음과 같이 JPAQueryFactory 를 스프링 빈으로 등록해서 주입받아 사용해도 된다.
  • 이렇게 설정하면 @RequiredArgsConstructor로 깔끔하게 Repository 코드를 작성할 수 있다.
참고: 동시성 문제는 걱정하지 않아도 된다. 왜냐하면 여기서 스프링이 주입해주는 엔티티 매니저는 실제 동작 시점에 진짜 엔티티 매니저를 찾아주는 프록시용 가짜 엔티티 매니저이다. 이 가짜 엔티티 매니저는 실제 사용 시점 에 트랜잭션 단위로 실제 엔티티 매니저(영속성 컨텍스트)를 할당해준다.

 

🤔 스프링이 주입해주는 엔티티 매니저는 가짜?

Spring에서 @PersistenceContext 등을 사용해서 EntityManager를 주입하면, 실제로 우리가 받는 것은 진짜 엔티티 매니저가 아니라 프록시(proxy) 객체이다. 이 프록시는 우리가 직접 EntityManager를 사용하는 시점까지 실제 엔티티 매니저를 생성하지 않고 있다가, 트랜잭션이 시작되면 그때 진짜 엔티티 매니저를 찾아서 연결해준다.즉, 우리가 EntityManager를 주입받았다고 해서 항상 같은 객체가 있는 것이 아니라, 매번 트랜잭션이 열릴 때 적절한 엔티티 매니저가 제공되는 것이다.

 

트랜잭션과 영속성 컨텍스트의 관계

  • 엔티티 매니저는 영속성 컨텍스트(Persistence Context)를 관리하는 역할을 한다.
  • 영속성 컨텍스트는 쉽게 말해 현재 트랜잭션에서 관리되는 엔티티들을 저장하는 공간이다.
  • 트랜잭션이 시작되면, 스프링은 새로운 영속성 컨텍스트를 할당하고, 그 안에서 엔티티들을 관리한다.
  • 트랜잭션이 끝나면 영속성 컨텍스트도 함께 종료된다.

동시성 문제가 발생하지 않는 이유

여러 개의 스레드가 동시에 같은 EntityManager를 사용한다면 문제가 될 수 있지만, 다음과 같은 이유로 동시성 문제가 없다.

  1. 각 트랜잭션마다 새로운 엔티티 매니저가 제공됨
    • 스프링이 관리하는 엔티티 매니저는 실제 사용 시점까지 "진짜 엔티티 매니저"를 할당하지 않는다.
    • 트랜잭션이 시작되면 새로운 엔티티 매니저(영속성 컨텍스트)가 생성되어 각 트랜잭션별로 독립적으로 관리된다.
  2. 각 요청(스레드)은 자신만의 트랜잭션과 엔티티 매니저를 가짐
    • 즉, A 사용자의 요청과 B 사용자의 요청이 동시에 발생하더라도, 각각의 요청은 서로 다른 트랜잭션을 사용한다.
    • 이로 인해 트랜잭션마다 독립적인 영속성 컨텍스트가 할당되므로 충돌이 발생하지 않는다.
  3. 엔티티 매니저가 프록시이기 때문
    • 엔티티 매니저는 내부적으로 ThreadLocal을 사용하여 현재 실행 중인 스레드에 맞는 실제 엔티티 매니저를 찾아서 연결한다.
    • 즉, 같은 EntityManager 객체를 주입받았더라도, 내부적으로는 각 스레드마다 다른 엔티티 매니저를 사용하고 있다.

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

MemberTeamDto - 조회 최적화용 DTO 추가

@Data
public class MemberTeamDto {

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

    @QueryProjection
    public MemberTeamDto(Long memberId, String username, int age, Long teamId, Long teamName) {
        this.memberId = memberId;
        this.username = username;
        this.age = age;
        this.teamId = teamId;
        this.teamName = teamName;
    }
}
  • @QueryProjection 을 추가했다.
  • QMemberTeamDto 를 생성하기 위해 ./gradlew compileJava 을 한번 실행하자.
참고: @QueryProjection 을 사용하면 해당 DTO가 Querydsl을 의존하게 된다. 이런 의존이 싫으면, 해당 에노테이션을 제거하고, Projection.bean(), fields(), constructor() 을 사용하면 된다.

 

회원 검색 조건

@Data
public class MemberSearchCondition {

    // 회원명, 팀명, 나이(ageGoe, ageLoe)
    private String username;
    private String teamName;
    private Integer ageGoe;
    private Integer ageLoe;
}

 

동적쿼리 - Builder 사용

import static study.querydsl.entity.QMember.*;
import static study.querydsl.entity.QTeam.*;

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(member.team.name.eq(condition.getTeamName()));
    }
    if (condition.getAgeGoe() != null) {
        builder.and(member.age.goe(condition.getAgeGoe()));
    }
    if (condition.getAgeLoe() != null) {
        builder.and(member.age.loe(condition.getAgeLoe()));
    }

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(member.team.name.eq(condition.getTeamName()));
        }
        if (condition.getAgeGoe() != null) {
            builder.and(member.age.goe(condition.getAgeGoe()));
        }
        if (condition.getAgeLoe() != null) {
            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();

    }

}
  • QMemberTeamDto 는 생성자를 사용하기 때문에 필드 이름을 맞추지 않아도 된다.
  • 따라서 member.id 만 적으면 된다.

조회 예제 테스트

@Test
public void searchTest() {
    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);

    MemberSearchCondition condition = new MemberSearchCondition();
    condition.setAgeGoe(35);
    condition.setAgeLoe(40);
    condition.setTeamName("teamB");

    List<MemberTeamDto> result = memberJpaRepository.searchByBuilder(condition);
    Assertions.assertThat(result).extracting("username").containsExactly("member4");
}

 

🚨 만약 모든 조건이 없다면?

MemberSearchCondition condition = new MemberSearchCondition();
// condition.setAgeGoe(35);
// condition.setAgeLoe(40);
// condition.setTeamName("teamB");
  • 당연히 모든 데이터를 끌고 와버린다!
  • 그러니 기본 조건이나 리미트를 걸어서 퍼올리는 데이터를 제한해두자.
  • 가급적 페이징 쿼리랑 함께!

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

Where절에 파라미터를 사용한 예제

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

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;
}
private BooleanExpression ageGoe(Integer age) {
    return age != null ? member.age.goe(age) : null;
}
private BooleanExpression ageLoe(Integer age) {
    return age != null ? member.age.loe(age) : null;
}

 

참고: where 절에 파라미터 방식을 사용하면 조건 재사용 가능

//where 파라미터 방식은 이런식으로 재사용이 가능하다.
public List<Member> findMember(MemberSearchCondition condition) {
     return queryFactory
             .selectFrom(member)
             .leftJoin(member.team, team)
             .where(usernameEq(condition.getUsername()),
                     teamNameEq(condition.getTeamName()),
                     ageGoe(condition.getAgeGoe()),
 
 }