[JPA]/실전! Querydsl

[Querydsl] 실무 활용 - 순수 JPA와 Querydsl

쿠릉쿠릉 쾅쾅 2022. 4. 27. 23:14
728x90

 

순수 JPA 리포지토리와 Querydsl

1. 순수 JPA 리포지토리

📌 MemberJpaRepository

package study.querydsl.repository;

@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {

    private final EntityManager 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> findByUsername(String username) {
        return em.createQuery("select m from Member m where m.username = :username", Member.class)
                .setParameter("username", username)
                .getResultList();
    }
}

먼저 순수 JPA 리포지토리를 만들었다.

📌 MemberJpaRepositoryTest

package study.querydsl.repository;

@SpringBootTest
@Transactional
class MemberJpaRepositoryTest {

    @Autowired
    MemberJpaRepository memberJpaRepository;

    @Test
    void basicTest() {
        Member member = new Member("member1", 10);
        memberJpaRepository.save(member);

        Member findMember = memberJpaRepository.findById(member.getId()).orElse(null);
        assertThat(member).isEqualTo(findMember);

        List<Member> result1 = memberJpaRepository.findAll();
        assertThat(result1).containsExactly(member);

        List<Member> result2 = memberJpaRepository.findByUsername("member1");
        assertThat(result2).containsExactly(member);

    }
}

순수 JPA 리포지토리 기능 테스트.

이제 순수 JPA 리포지토리의 메서드들을 Querydsl로 똑같은 기능을 구현하도록 하겠다. 먼저 JPAQueryFactory를 주입 받기 위해 스프링 빈으로 등록하도록 하자.

📌 QuerydslApplication (루트 클래스)

package study.querydsl;

@SpringBootApplication
public class QuerydslApplication {

   public static void main(String[] args) {
      SpringApplication.run(QuerydslApplication.class, args);
   }

   // JPAQueryFactory 빈 등록
   @Bean
   JPAQueryFactory jpaQueryFactory(EntityManager em) {
      return new JPAQueryFactory(em);
   }
}

 

참고로 JPA의 QueryFactory의  동시성 문제는 모두 엔티티 매니저에 의존한다. 스프링이 주입해주는 엔티티 매니저는 실제 동작 시점에 진짜 매니저를 찾아주는 프록시용 가짜 엔티티 매니저다. 이 가짜 엔티티 매니저는 실제 사용 시점에 트랜잭션 단위로 실제 엔티티 매니저(영속성 컨텍스트)를 할당해준다.

JPAQueryFactory를 스프링 빈으로 등록했으니 이제 MemberJpaRepository에서 순수 JPA로 작성했던 코드를 Querydsl로 바꿔보자.

📌 MemberJpaRepository

package study.querydsl.repository;

@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 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();
    }

    // findAll()을 querydsl로 구현
    public List<Member> findAll_Querydsl() {
        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();
    }

    // findByUsername()을 querydsl로 구현
    public List<Member> findByUsername_Querydsl(String username) {
        return queryFactory
                .selectFrom(member)
                .where(member.username.eq(username))
                .fetch();
    }
}

📌 MemberJpaRepository

package study.querydsl.repository;

@SpringBootTest
@Transactional
class MemberJpaRepositoryTest {

    @Autowired
    MemberJpaRepository memberJpaRepository;

    @Test
    void basicQuerydslTest() {
        Member member = new Member("member1", 10);
        memberJpaRepository.save(member);

        Member findMember = memberJpaRepository.findById(member.getId()).orElse(null);
        assertThat(member).isEqualTo(findMember);

        List<Member> result1 = memberJpaRepository.findAll_Querydsl();
        assertThat(result1).containsExactly(member);

        List<Member> result2 = memberJpaRepository.findByUsername_Querydsl("member1");
        assertThat(result2).containsExactly(member);

    }
}

 


 

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

조회 최적화용 DTO를 추가하자.

📌 MemberTeamDto

package study.querydsl.dto;

@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;
    }
}

QMemberTeamDto를 생성하기 위해 @QueryProjection을 사용했다.
compileQuerydsl을 실행시켜서 Q파일을 생성할 것.

이제 회원 검색 조건 객체를 정의하도록 하자.

📌 MemberSearchCondition

package study.querydsl.dto;

import lombok.Data;

@Data
public class MemberSearchCondition {

    /**
     * 검색 조건
     * 회원명, 팀명, 나이(ageGoe, ageLoe)
     */
    private String username;
    private String teamName;
    private Integer ageGoe;
    private Integer ageLoe;
}

이제 리포지토리에서 Builder를 이용하여 동적쿼리를 생성하도록 하자.

📌 MemberJpaRepository

package study.querydsl.repository;

@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {

    private final EntityManager em;
    private final JPAQueryFactory queryFactory;

    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 (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();
    }
    
    ...
}

이제 이 동적 쿼리를 테스트해보자.

📌 MemberJpaRepositoryTest

package study.querydsl.repository;

@SpringBootTest
@Transactional
class MemberJpaRepositoryTest {

    @Autowired
    MemberJpaRepository memberJpaRepository;

    @Autowired
    EntityManager em;

    /**
     * 검색 조건
     *  - 나이 5 이상
     *  - 나이 40 이하
     *  - 팀명이 'teamB'인 경우
     */
    @Test
    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);
        
        em.flush();
        em.clear();

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

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

        assertThat(result).extracting("username").containsExactly("member4");
        
    }
}
    /* select
        member1.id,
        member1.username,
        member1.age,
        team.id,
        team.name 
    from
        Member member1   
    left join
        member1.team as team 
    where
        team.name = ?1 
        and member1.age >= ?2 
        and member1.age <= ?3 */ select
            member0_.member_id as col_0_0_,
            member0_.username as col_1_0_,
            member0_.age as col_2_0_,
            team1_.team_id as col_3_0_,
            team1_.name as col_4_0_ 
        from
            member member0_ 
        left outer join
            team team1_ 
                on member0_.team_id=team1_.team_id 
        where
            team1_.name=? 
            and member0_.age>=? 
            and member0_.age<=?

검색 조건에 해당하는 동적쿼리가 생성된 것을 알 수 있다.
나중에 여기에 페이징처리를 추가하면 된다.

만약에 검색 조건이 아무것도 없으면 어떻게 될까? 이것도 코드로 확인해보자.

📌 MemberJpaRepositoryTest

package study.querydsl.repository;

@SpringBootTest
@Transactional
class MemberJpaRepositoryTest {

    @Autowired
    MemberJpaRepository memberJpaRepository;

    @Autowired
    EntityManager em;

    /**
     * 검색 조건에 아무런 조건이 없을 경우
     */
    @Test
    void searchTest2() {
        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();
        List<MemberTeamDto> result = memberJpaRepository.searchByBuilder(condition);

        for (MemberTeamDto memberTeamDto : result) {
            System.out.println("memberTeamDto.getUsername() = " + memberTeamDto.getUsername());
        }
    }
}
    /* select
        member1.id,
        member1.username,
        member1.age,
        team.id,
        team.name 
    from
        Member member1   
    left join
        member1.team as team */ select
            member0_.member_id as col_0_0_,
            member0_.username as col_1_0_,
            member0_.age as col_2_0_,
            team1_.team_id as col_3_0_,
            team1_.name as col_4_0_ 
        from
            member member0_ 
        left outer join
            team team1_ 
                on member0_.team_id=team1_.team_id
                
memberTeamDto.getUsername() = member1
memberTeamDto.getUsername() = member2
memberTeamDto.getUsername() = member3
memberTeamDto.getUsername() = member4

검색 조건이 없을시에는 모든 데이터를 조회하는 것을 알 수 있다.
만약에 데이터 수가 적으면 모든 데이터를 조회해도 상관 없지만 데이터 수가 많을 경우 큰 장애로 이어질 수 있다. 그러므로 검색 조건이 아예 없을 경우를 대비해서 방어 처리 코드를 작성해야 한다.

 


 

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

기본의 BooleanBuilder로 동적 쿼리를 처리한 것을 Where절에 다중 파라미터 방식으로 바꿔보도록 하겠다.

📌 MemberJpaRepository

package study.querydsl.repository;

@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {

    private final EntityManager em;
    private final JPAQueryFactory queryFactory;

    /**
     * 검색 조건 동적 쿼리
     * 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 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 ageGoe) {
        return ageGoe != null ? member.age.goe(ageGoe) : null;
    }

    private BooleanExpression ageLoe(Integer ageLoe) {
        return ageLoe != null ? member.age.loe(ageLoe) : null;
    }
    
    ...
}

📌 MemberJpaRepositoryTest

package study.querydsl.repository;

@SpringBootTest
@Transactional
class MemberJpaRepositoryTest {

    @Autowired
    MemberJpaRepository memberJpaRepository;

    @Autowired
    EntityManager em;

    /**
     * 검색 조건
     *  - 나이 5 이상
     *  - 나이 40 이하
     *  - 팀명이 'teamB'인 경우
     */
    @Test
    void searchTest3() {
        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);

        em.flush();
        em.clear();

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

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

        assertThat(result).extracting("username").containsExactly("member4");

    }
}

 


 

조회 API 컨트롤러 개발

조회용 API를 만들기 전에 샘플 데이터를 추가해야한다. 근데 샘플 데이터를 만들기 전에 샘플 데이터가 추가가 테스트 케이스 실행에 영향을 주지 않기 위해서 설정 파일에서 프로파일을 설정해야 한다.

1. 프로파일 설정

📌 src/main/resources/application.yml

spring:
  profiles:
    active: local

  output.ansi.enabled: always

  datasource:
    url: jdbc:h2:tcp://localhost/~/querydsl
    username: sa
    password:
    driver-class-name: org.h2.Driver

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
  # show_sql: true
        format_sql: true
        use_sql_comments: true

#  data:
#    web:
#      pageable:
#        default-page-size: 5
#        max-page-size: 2000
#        one-indexed-parameters: true



logging.level:
  org.hibernate.SQL: debug
# org.hibernate.type: trace

여기서 'spring.profiles.active=[명칭]'으로 지정한 것이 중요하다.
보통 로컬 서버에는 'local', 개발서버에는 'dev', 운영서버에는 'real', 테스트 서버에는 'test'로 지정한다 

📌 src/test/resources/application.yml

spring:
  profiles:
    active: test
    
  output.ansi.enabled: always

  datasource:
    url: jdbc:h2:tcp://localhost/~/querydsl
    username: sa
    password:
    driver-class-name: org.h2.Driver

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
  # show_sql: true
        format_sql: true
        use_sql_comments: true


logging.level:
  org.hibernate.SQL: debug
# org.hibernate.type: trace

테스트 서버에는 profiles의 active 명칭을 test로 구분하여 로컬서버와 테스트 서버를 서로 분리했다.
이렇게 분리하면 main 소스 코드와 테스트 소스 코드 실행시 프로파일을 분리할 수 있다.

 

2. 샘플 데이터 추가

package study.querydsl.controller;

@Profile("local")
@Component
@RequiredArgsConstructor
public class InitMember {

    private final InitMemberService initMemberService;

    @PostConstruct
    public void init() {
        initMemberService.init();
    }

    @Component
    static class InitMemberService {
        @PersistenceContext
        private EntityManager em;

        @Transactional
        public void init() {
            Team teamA = new Team("teamA");
            Team teamB = new Team("teamB");
            em.persist(teamA);
            em.persist(teamB);

            for (int i=0; i<100; i++) {
                Team selectedTeam = i%2==0 ? teamA : teamB;
                em.persist(new Member("member" + i, i, selectedTeam));
            }
        }
    }
}

@Profile 애노테이션의 속성값을 'local'로 설정하여 'local'로 지정된 서버만 실행되도록 설정했다.
그러므로 테스트 코드에서는 해당 코드가 실행되지 않는다.
@Profile 애노테이션에 속성 값으로 들어가는 값이 프로파일의 현재 값과 일치할 시 스프링 빈으로 등록한다.

로컬서버를 실행시키면 저렇게 profiles의 active를 지정된 명칭이 나온다.

만약에 테스트서버를 실행시켰다면 아래의 그림처럼 test로 나온다.

🧷 참고. @PostConstruct 와 @Transactional은 같이 사용할 수 없다.

@PostConstruct는 해당 빈 자체만 생성되었다고 가정하고 호출됩니다. 해당 빈에 관련된 AOP등을 포함한, 전체 스프링 애플리케이션 컨텍스트가 초기화 된 것을 의미하지는 않습니다.

트랜잭션을 처리하는 AOP등은 스프링의 후 처리기(post processer)가 완전히 동작을 끝내서, 스프링 애플리케이션 컨텍스트의 초기화가 완료되어야 적용됩니다.

정리하면 @PostConstruct는 해당빈의 AOP 적용을 보장하지 않습니다.

이런 것을 우회하는 여러가지 방법이있는데요. 제가 보여드린 방법(다른 스프링 빈을 호출해서 사용하는 방법)을 포함해서, AOP를 사용하지 않고 트랜잭션을 직접 코딩하는 방법, 애플리케이션 컨텍스트가 완전히 초기화 된 이벤트를 받아서 호출하는 방법 등이 있습니다.

관련해서 다음 링크를 참고해보시면 더욱 자세한 도움을 받을 수 있습니다^^

https://sorjfkrh5078.tistory.com/311

 

 

 

3. 조회 API 컨트롤러

package study.querydsl.controller;

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberJpaRepository memberJpaRepository;

    @GetMapping("/v1/members")
    public List<MemberTeamDto> searchMemberV1(MemberSearchCondition condition) {
        return memberJpaRepository.search(condition);
    }
}

 

4. 포스트맨 실행

'http://localhost:8080/v1/members?teamName=teamB&ageGoe=31&ageLoe=35' 

검색 조건이 잘 나온것을 알 수 있다.

 

 

 

 

 

 


👀 참고 자료

https://www.inflearn.com/course/Querydsl-%EC%8B%A4%EC%A0%84/dashboard

 

실전! Querydsl - 인프런 | 강의

Querydsl의 기초부터 실무 활용까지, 한번에 해결해보세요!, - 강의 소개 | 인프런...

www.inflearn.com

 

https://sorjfkrh5078.tistory.com/311

 

[Spring] @PostConstruct에서 @Transactional 처리 시 문제점

간단한 웹 프로젝트를 진행하고 있는데 테스트할 때마다 매번 DB에 데이터들을 추가해줘야 하는 번거로움이 존재하였다. 이를 해결하기 위해 TestData이라는 클래스를 스프링 빈으로 등록한 후 ini

sorjfkrh5078.tistory.com

 

 

728x90