순수 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> findByUsername(String username){
return em.createQuery("select m from Member m where m.username = :username", Member.class)
.setParameter("username", username)
.getResultList();
}
}
Repository를 만들고 EntityManager를 이용하여 메소드를 만들었다.
@SpringBootTest
@Transactional
class MemberJpaRepositoryTest {
@Autowired
EntityManager em;
@Autowired
MemberJpaRepository memberJpaRepository;
@Test
public void basicTest(){
Member member = new Member("member1",10);
memberJpaRepository.save(member);
Member findMember = memberJpaRepository.findById(member.getId()).get();
Assertions.assertThat(findMember).isEqualTo(member);
List<Member> members = memberJpaRepository.findAll();
Assertions.assertThat(members).containsExactly(member);
List<Member> searchMember = memberJpaRepository.findByUsername("member1");
Assertions.assertThat(searchMember).containsExactly(member);
}
}
MemberJpaRepository를 이용한 테스트를 작성했다. 이제 MemberJpaRepository를 Querydsl을 사용하는 것으로 변경해 보겠다.
Querydsl 사용 메소드
public List<Member> findAll_Querydsl(){
return queryFactory
.selectFrom(member)
.fetch();
}
public List<Member> findByUsername_Querydsl(String username){
return queryFactory
.selectFrom(member)
.where(member.username.eq(username))
.fetch();
}
findAll()과 findByUsername()을 Querydsl버전으로 만들어 보았다.
@Test
public void basicQuerydslTest(){
Member member = new Member("member1",10);
memberJpaRepository.save(member);
List<Member> members = memberJpaRepository.findAll_Querydsl();
Assertions.assertThat(members).containsExactly(member);
List<Member> searchMember = memberJpaRepository.findByUsername_Querydsl("member1");
Assertions.assertThat(searchMember).containsExactly(member);
}
JPAQueryFactory Bean 등록
@SpringBootApplication
public class QuerydslApplication {
public static void main(String[] args) {
SpringApplication.run(QuerydslApplication.class);
}
@Bean
JPAQueryFactory jpaQueryFactory(EntityManager em){
return new JPAQueryFactory(em);
}
}
public MemberJpaRepository(EntityManager em, JPAQueryFactory queryFactory){
this.em = em;
this.queryFactory = queryFactory;
// this.queryFactory = new JPAQueryFactory(em);
}
위의 방식처럼 JPAQueryFactory를 Bean으로 등록하여 사용할 수도 있다. 위의 방식을 사용하면 저 생성자 코드를 지우고 @RequiredArgsConstructor로 대체할 수 있다는 장점이 있다.
위의 코드들을 보면 JPAQueryFactory에 대한 동시성 문제가 있지 않을까? 라는 생각이 든다. 왜냐하면 모든 스레드들이 같은 것을 사용하기 때문이다.
다행히 문제가 없다. JPAQueryFactory에 대한 동시성 문제는 EntityManager에 의존하는데 이를 Spring과 함께 사용하면 동시성 문제 없이 트랜잭션 단위로 분리되어 동작한다. Spring에서 EntityManager는 진짜 영속성 컨텍스트 EntityManager가 아니라 프록시인데 이게 트랜잭션 단위로 각각 바인딩 되도록 라우팅만 해준다.
Builder를 사용한 동적 쿼리와 성능 최적화 조회
우선 데이터를 담을 dto를 아래와 같이 2개 만든다.
@Data
public class MemberTeamDto {
private Long memberId;
private String username;
private int age;
private Long teamId;
private String teamName;
public MemberTeamDto(){};
@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;
}
}
member와 team정보를 동시에 담을 DTO이다.
@Data
public class MemberSearchCondition {
//회원명, 팀명, 나이(ageGoe, ageLoe)
private String username;
private String teamName;
private Integer ageGoe;
private Integer ageLoe;
}
검색한 경우에 사용할 DTO이다.
MemberJpaRepository
...
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.as("memberId")
member.username,
member.age,
team.id.as("teamId")
team.name.as("teamName")
))
.from(member)
.leftJoin(member.team, team)
.where(builder)
.fetch();
}
...
BooleanBuilder를 사용하여 조건을 추가하며 동적 쿼리를 생성한다.
이때 StringUtils.hasText()(org.springframework.util.StringUtils)를 사용했는데 이걸 자세히 보면
null뿐만 아니라 비어있는 문자열인지도 체크한다. 실제 웹 서비스를 운영하면 null보다 비어있는 “”과 같은 데이터가 들어오는 경우가 많기 때문에 사용했다.
위의 방식처럼 사용하면 Builder로 동적 쿼리를 만듦과 동시에 DTO를 사용하여 쿼리 한 번에 뽑아오는 최적화까지 완료된 것이다.
@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");
}
만들어 놓은 searchByBuilder()를 사용하여 이름, 팀이 같으면 조건에 들어가고 나이는 Goe 이상, Loe이하인 값을 받게 하였다. 위의 member중에서는 member4가 나와야하고 테스트가 통과했으니 문제가 없는 것이다.
쿼리를 확인해보니 원하던대로 잘 간 것 또한 확인할 수 있었다.
여기서 주의할 점이 있는데 데이터가 없을 때 결과이다.
이렇게 condition에 데이터를 넣지 않아 조건이 없다면 쿼리는 아래와 같이 나간다.
데이터를 전부 다 긁어온다. 이게 문제가 되는 것이 실제 서비스를 운영하면 데이터가 많을 것이다. 그런데 이렇게 전부 다 가져온다면 내가 예상한 데이터는 매우 양이 적어야 하지만 실제로는 엄청 많은 데이터를 조회해서 문제가 생길 수 있다. 그래서 동적 쿼리를 작성할 땐 limit을 적용하거나 페이징을 적용하거나 최소한의 조건을 추가하는 습관을 가지는 것이 좋다.
Where절 파라미터 사용법
public List<MemberTeamDto> searchByParam(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 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;
}
where() 절에 메소드를 파라미터로 넣어 조건을 걸었다. 각 메소드들 반환이 BooleanExpression으로 하면 메소드로 조합할 수 있는 장점이 있다. 그렇다면 저 나열된 메소드들을 하나로 묶어줄 수도 있다.
또한 Builder에 비해 코드가 깔끔하게 분리되었다. 훨씬 가독성이 좋다는 장점이 있다. 가장 큰 장점은 저 메소드들을 재사용할 수 있다는 것이다. null만 조심한다면 정말 좋은 장점이다.
private BooleanExpression ageBetween(int ageLoe, int ageGoe){
return ageGoe(ageGoe).and(ageLoe(ageLoe));
}
이렇게 원하는대로 조립하면서 재사용 할 수 있다. 그럼 where()절의 ageGoe(), ageLoe()를 대체할 수 있고 다른 메소드들을 생성할 때도 이런 방법으로 다양하게 활용할 수 있다.
@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(20);
condition.setAgeLoe(40);
condition.setTeamName("teamB");
// List<MemberTeamDto> result = memberJpaRepository.searchByBuilder(condition);
List<MemberTeamDto> result = memberJpaRepository.searchByParam(condition);
Assertions.assertThat(result).extracting("username").containsExactly("member3","member4");
}
마찬가지로 테스트를 돌려보면 통과과 된다.
쿼리도 설계한대로 잘 나간 것을 확인할 수 있다.
API에 적용
적용하기 전에 개발용과 테스트용을 분리해보겠다.
resources/application.yml 파일에 profiles를 지정해준다
resources/application.yml
spring:
profiles:
active: local
이러면 얘는 Profile이 local일 때 동작하는 것이다
테스트 파일에도 resources/application.yml을 만들어준다.
Test 폴더에 만드는 resources/application.yml
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 selectedTeam = i %2 == 0? teamA : teamB;
em.persist(new Member("member"+i, i, selectedTeam));
}
}
}
}
초기 데이터를 넣기 위한 클래스이다. 메인 클래스를 실행하면 기본 설정이 local이기 때문에 @Profile(”local”)을 보고 동작하게 된다. 그러면서 @PostConstruct(의존성 주입이 이루어진 후 초기화를 실행하는 메소드이다. 다른 곳에서 호출되지 않아도 실행된다.)가 실행된다. 그럼 initMemberService.init()을 호출하고 데이터를 넣고 시작하게 되는 것이다. @PersistenceContext는 EntityManager를 스프링이 만들어둔 Bean을 주입할 때 사용하는 어노테이션이다.
이때 init()을 PostCunstruct에 넣으면 안되냐고 할 수 있는데 @Transactional이 @PostConstruct와 함께 동작할 수 없어서 안된다. 이는 스프링 라이프사이클상 불가능하다고 한다.
이렇게 나눴기 때문에 해당 초기화 클래스는 테스트 환경에서는 동작하지 않는다. 이건 profile이 local일 때만 동작하기 때문이다. 이렇게 테스트 할 땐 테스트 환경에서만 하고 로컬 환경에서 필요한 동작들은 로컬에서만 하기 위해 나누는 것이다.
실행하면 데이터들이 들어간 것을 확인할 수 있다.
또한 profile이 “local”로 실행된 것도 확인할 수 있다. 이게 내가 의도한 것과 잘못되어 있다면 무언가 문제가 있는 것이다.
이제 간단하게 컨트롤러 만들어서 테스트를 해보면
@RestController
@RequiredArgsConstructor
public class MemberController {
private final MemberJpaRepository memberJpaRepository;
@GetMapping("/v1/members")
public List<MemberTeamDto> searchMemberV1(MemberSearchCondition condition){
return memberJpaRepository.searchByParam(condition);
}
}
member번호는 0부터 시작했으니 Id보다 1이 작은 값이 나오는걸 보니 잘 나오는 것을 확인할 수 있다.
이런식으로 조건도 잘 들어간다!
이거하면서 알았는데 Get요청 받을 때 @RequestParam없이도 DTO로 받을 수 있는건 처음 알았다. 어노테이션 생략하는건 각 필드명과 타입이 일치하면 생략되는 줄 알았는데(String username과 같이 정확히 일치하면 생략가능) DTO도 된다. DTO는 안들어온 데이터는 자동으로 null이되고 null은 만들어 놓은 메소드에서 걸러진다.
'Java > Querydsl' 카테고리의 다른 글
[Querydsl] Spring Data JPA + Querydsl , Pageable(페이징) (0) | 2023.07.09 |
---|---|
[Querydsl] 프로젝션, 동적 쿼리, 배치 쿼리(벌크 연산) (0) | 2023.07.08 |
[Querydsl] Querydsl 기본문법 (0) | 2023.07.08 |
[Querydsl] Querdydsl 설정하기 (0) | 2023.07.03 |