[인강노트 - querydsl] 6. 실무 활용 - 순수 JPA와 Querydsl

봄도둑·2023년 1월 3일
0

김영한님의 실전! querydsl 강의 내용을 정리한 노트입니다. 블로그에 있는 자료를 사용하실 때에는 꼭 김영한님 강의 링크를 남겨주세요!

1. 순수 JPA 리포지토리와 Querydsl

  • 일단 학습에 필요한 순수 JPA repository를 만들어보자!
package study.querydsl.repository;

import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.stereotype.Repository;
import study.querydsl.entity.Member;

import javax.persistence.EntityManager;
import java.util.List;
import java.util.Optional;

@Repository
public class MemberJpaRepository {

    //순수 JPA로 할 것이기 때문에 EntityManager를 사용하자
    private final EntityManager em;
    private final JPAQueryFactory queryFactory;
		
		//이 생성자는 취향의 영역이긴 한데 EntityManger를 받아서 새롭게 queryFactory를 만들어도 되지만
		//queryFactory를 bean으로 올려서 사용해도 됨
    public MemberJpaRepository(EntityManager em) {
        this.em = em;
        this.queryFactory = new JPAQueryFactory(em);
    }

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

    //null로 리턴될 수 있기 때문에 Optional 사용
    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();
    }
} 
  • 번외로 queryFactory를 bean으로 등록하기 → 이건 취향의 영역, 요렇게 쓰면 lombok의 @RequiredArgsConstructor 를 편하게 사용할 수 있음. 단 외부에서 JPAQueryFactory를 주입 받아야 하기 때문에 테스트 코드를 작성할 때 불편할 수 있음
    @SpringBootApplication
    public class QuerydslApplication {
    
    	public static void main(String[] args) {
    		SpringApplication.run(QuerydslApplication.class, args);
    	}
    
    	@Bean
    	JPAQueryFactory jpaQueryFactory(EntityManager em) {
    		return new JPAQueryFactory(em);
    	}
    
    }
    
    @Repository
    public class MemberJpaRepository {
    
        //..
    		
    		//bean으로 queryFactory 받기
        public MemberJpaRepository(EntityManager em, JPAQueryFactory queryFactory) {
            this.em = em;
            this.queryFactory = queryFactory;
        }
    
        //..
    } 
  • ctrl + alt + b : 해당 메소드(혹은 변수)가 참조하고 있는 원래 위치로 이동 → ctrl + 우클릭 과 동일하게 동작
  • 일단 간단히 해당 MemberRepository가 동작하는지 확인해보자
@SpringBootTest
@Transactional
class MemberJpaRepositoryTest {

    @Autowired
    EntityManager em;

    @Autowired
    MemberJpaRepository memberJpaRepository;

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

        //원래 optional로 리턴 받아서 쓰고 있기 때문에 get으로 바로 받아오는 게 좋지 않음. 테스트인 것 감안!!!
        Member findMember = memberJpaRepository.findById(member1.getId()).get();
        assertThat(findMember).isEqualTo(member1);

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

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

}
  • 잘 동작된다면, repository에 있는 녀석들을 querydsl로 바꿔보자 → 비교를 위해 _Querydsl 이 붙은 메소드 추가
public List<Member> findAll() {
    return em.createQuery("select m from Member m", Member.class)
            .getResultList();
}

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

public List<Member> findByUsername_Querydsl(String username) {
    return queryFactory
            .selectFrom(member)
            .where(member.username.eq(username))
            .fetch();
}
  • 잘 동작하는지 테스트도 해보기
@Test
public void basicQuerydslTest() {
    Member member1 = new Member("member1", 10);
    memberJpaRepository.save(member1);

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

    List<Member> result2 = memberJpaRepository.findByUsername_Querydsl("member1");
    assertThat(result2).containsExactly(member1);
}
  • JPAQueryFactory에 대한 동시성 문제는 모두 EntityManger에 의존함 → EntityManager가 스프링과 엮여서 사용되면 트렌젝션 단위로 따로따로 동작하게 됨
    • spring이 주입해서 사용되는 EntityManager는 진짜 EntityManager가 아니라 프록싱에서 주입해준 가짜 EntityManager임 ⇒ 결론은 동시성 문제에 대해 아무 문제 없다!

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

  • 일단 member와 team 정보를 한 번에 받아올 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;
    }
}
  • @QueryProjection 의 사용 시 단점은 DTO가 순수해야 하는데 그렇지 못하고 DTO가 querydsl에 의존하게 되는 문제를 만듦.
  • 검색 조건을 담을 dto도 하나 만들자
@Data
public class MemberSearchCondition {
    //회원명, 팀명, 나이(ageGoe, ageLoe)에 대한 필터

    private String username;
    private String teamName;

    //Integer의 사용 이유 : 값이 null 일 수 도 있기 때문
    private Integer ageGoe;
    private Integer ageLoe;
}
  • 위에서 만들었던 MemberJpaRepository에 검색해서 찾아오는 메소드를 추가하자 → 이렇게 하면 builder를 이용해서 동적 쿼리도 다룰 수 있게 되고 한 번에 MemberTeamDto를 조회하기 때문에 성능 최적화도 가능함
public List<MemberTeamDto> searchByBuilder(MemberSearchCondition condition) {
    BooleanBuilder builder = new BooleanBuilder();

    //StringUtils
    if (hasText(condition.getUsername())) {
        builder.and(member.username.eq(condition.getUsername()));
    }

    if (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.as("memberId"),
                    member.username,
                    member.age,
                    team.id.as("teamId"),
                    team.name.as("teamName")
            ))
            .from(member)
            .leftJoin(member.team, team)
            .where(builder)
            .fetch();
}
  • 중괄호 자동 완성 단축키 : ctrl + shift + enter
  • 테스트를 해보자!
@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);

    assertThat(result).extracting("username").containsExactly("member4");
}
  • 쿼리는 의도한대로 날아감
  • 그런데 이 코드의 문제점이 하나 있음 → 만약 MemberSearchCondition에 아무런 값을 세팅하지 않고 날리게 되면????
@Test
public void searchTest() {
    MemberSearchCondition condition = new MemberSearchCondition();
    /*condition.setAgeGoe(35);
    condition.setAgeLoe(40);
    condition.setTeamName("teamB");*/

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

    for (MemberTeamDto memberTeamDto : result) {
        System.out.println("memberTeamDto : " + memberTeamDto);
    }
}
  • 결과는 모든 데이터를 조회 해버림 → where 조건이 아무것도 걸리지 않고 쿼리가 발사

  • 그래서 동적 쿼리를 짤 때에는 기본 조건을 세팅해주는 것이 좋고, 그렇지 않다면 limit라도 있는 것이 좋음


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

  • 이번엔 where절에 넣을 파라미터로 써보자! → MemberJpaRepository에 search 메소드를 추가해보자
public List<MemberTeamDto> search(MemberSearchCondition condition) {
    return queryFactory
            .select(new QMemberTeamDto(
                    member.id.as("memberId"),
                    member.username,
                    member.age,
                    team.id.as("teamId"),
                    team.name.as("teamName")
            ))
            .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 hasText(username) ? member.username.eq(username) : null;
}

private BooleanExpression teamNameEq(String teamName) {
    return 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;
}
  • BooleanExpression으로 리턴값을 갖게 될 경우, 이들을 조합해서 원하는 조건 메소드를 만드는 것이 가능함
  • 코드를 딱 보면 builder 같은 경우에 if 분기가 많이 들어가 있지만, where절 파라미터를 사용하는 경우 딱 queryFactory만 리턴하는 구조를 가지기 때문에 훨씬 더 보기 좋음 → 쓸데없이 분기 로직을 해석하는데 시간을 쓸 필요가 없음
  • 테스트 해보자!
@Test
public void searchTest() {
    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");
}
  • 이 방법의 가장 큰 장점은 다음과 같음 → 가령 member entity를 조회한다고 생각하면, where 조건을 재사용할 수 있음
public List<Member> searchMember(MemberSearchCondition condition) {
    return queryFactory
            .selectFrom(member)
            .leftJoin(member.team, team)
            .where(
                    usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe())
            )
            .fetch();
}
  • 조립도 가능함
public List<Member> searchMember(MemberSearchCondition condition) {
    return queryFactory
            .selectFrom(member)
            .leftJoin(member.team, team)
            .where(
                    usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageBetween(condition.getAgeLoe(), condition.getAgeGoe())
            )
            .fetch();
}

private BooleanExpression ageBetween(int ageLoe, int ageGoe) {
    return ageLoe(ageLoe).and(ageGoe(ageGoe));
}
  • 물론 동적 쿼리이기 때문에 null에 대해서는 신경을 잘 써야 함

4. 조회 API 컨트롤러 개발

테스트에 영향이 없도록 프로파일을 쪼갤 것

  • 테스트를 실행할 때랑 로컬에서 톰캣을 띄울 때랑 다른 상황으로 돌리기 위해 → 로컬에서 톰캣을 띄울 때 올라가는 환경에서 데이터가 대량으로 들어가도록 설정할 것
  • 먼저 기존에 쓰고 있는 yml에 profiles.active를 local로 세팅하자
spring:
  profiles:
    active: local
  • 그리고 test 아래 resources라는 디렉토리를 만들어주고 yml을 복사해놓자
  • 그리고 profiles.active를 test로 설정하자
spring:
  profiles:
    active: test
  • 다음은 데이터 초기화 하는 걸 추가하자
@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 selecetedTeam = i % 2 == 0 ? teamA : teamB;
                em.persist(new Member("member" + i, i, selecetedTeam));
            }
        }
    }
}
  • @PostConstruct : 의존성이 주입된 이후 바로 실행하는 메소드를 가리킴
  • 여기서 한 가지 의문이 들 수 있는데 InitMember의 init함수에서 바로 초기화 로직을 수행해주면 되는 걸 굳이 InitMemberService를 생성해서 호출을 하는걸까? → PostConstruct와 Transactional은 spring lifeCycle에 의해 둘을 같이 쓸 수 없음!!! → PostConstruct하는 부분과 Transactional 부분을 분리해서 사용해야 함
//PostConstruct와 Transactional은 spring lifeCycle에 의해 둘을 같이 쓸 수 없음
//요렇게 쓸 수 없다!!!! 쓰지말자!!!! 

@PostConstruct
@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 selecetedTeam = i % 2 == 0 ? teamA : teamB;
        em.persist(new Member("member" + i, i, selecetedTeam));
    }
}
  • 이제 초기화 세팅이 정상적으로 동작하는지 Spring boot를 실행해보자
  • 빌드 후, profiles가 정상적으로 local을 가리키는지 확인할 것
  • db에 값이 정상적으로 들어 갔는지도 확인해보자
  • API 호출을 위해 MemberController를 만들자
@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberJpaRepository memberJpaRepository;

    @GetMapping("v1/members")
    public List<MemberTeamDto> searchMemberV1(MemberSearchCondition condition) {
        return memberJpaRepository.search(condition);
    }
}
  • api를 쏴보자 → 결과가 잘 나오는 것을 볼 수 있음
  • 그럼 검색 조건을 넣어보자 → localhost:8080/v1/members?teamName=teamB
  • 나이 조건도 같이 써보자 → localhost:8080/v1/members?teamName=teamB&ageGoe=31&ageLoe=35
profile
Java Spring 백엔드 개발자입니다. java 외에도 다양하고 흥미로운 언어와 프레임워크를 학습하는 것을 좋아합니다.

0개의 댓글