관리 메뉴

나구리의 개발공부기록

실무 활용 - 순수 JPA 리포지토리와 Querydsl, 동적 쿼리와 성능 최적화 조회(Builder 사용, Where절 파라미터 사용), 조회 API 컨트롤러 개발 본문

인프런 - 스프링부트와 JPA실무 로드맵/실전! Querydsl

실무 활용 - 순수 JPA 리포지토리와 Querydsl, 동적 쿼리와 성능 최적화 조회(Builder 사용, Where절 파라미터 사용), 조회 API 컨트롤러 개발

소소한나구리 2024. 10. 30. 16:32

출처 : 인프런 - 실전! Querydsl (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


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

1) 순수 JPA

(1) 순수 JPA의 리포지토리 코드

  • JPA를 이용하여 구현하는 가장 기본적인 메서드들과 EntityManager와 JPAQueryFactory를 생성하는 생성자를 입력
package study.querydsl.repository;

@Repository
public class MemberJpaRepository {

    private final EntityManager em;
    private final JPAQueryFactory queryFactory;

    // 생성자로 EntityManager와 JPAQueryFactory를 생성할 수 있도록 작성
    public MemberJpaRepository(EntityManager em) {
        this.em = em;
        this.queryFactory = new JPAQueryFactory(em);
    }

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

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

 

(2) JPA 리포지토리 테스트

package study.querydsl.repository;

@SpringBootTest
@Transactional
class MemberJpaRepositoryTest {

    @Autowired EntityManager em;
    @Autowired MemberJpaRepository memberJpaRepository;

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

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

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

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

}

2) JPA를 Querydsl로 변경

(1) 기존 리포지토리코드에서 Querydsl 메서드로 변경, 테스트 생략

  • 순수 JPA로 작성한 findAll과 findByUsername의 메서드를 Querydsl로 변경
  • 문자로 쿼리를 작성해서 IDE의 도움을 받지 못했던 순수 JPA와 달리 Querydsl은 IDE의 도움을 받아 기능을 작성할 때에도 힌트를 얻기 수월하고 타입체크가 가능해 안전함
  • 해당 코드를 가지고 test를 수행해보면 정상적으로 동작함
/**
 * 순수 JPA 메서드들을 Querydsl 변환
 */
private final JPAQueryFactory queryFactory;

// 전체조회
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();
}

 

(2) JPAQueryFactory를 스프링 빈으로 등록

  • MemberJpaRepository에서는 생성자를 통해서 JPAQueryFactory의 객체를 생성하였는데, 이것을 스프링부트 클래스에서 스프링 Bean으로 등록하면 private final로 선언만 해주면 롬복의 @RequiredArgsConstructor를 통해서 생성자 주입 코드를 생략하여 간단히 주입 받을 수 있음
  • 그러나 두가지의 주입을 한번에 받게될 경우 테스트 코드를 작성할 때 조금 번거로울 수 있음
@SpringBootApplication
public class QuerydslApplication {
    // ... main 메서드 생략
	
	@Bean
	JPAQueryFactory jpaQueryFactory(EntityManager em) {
		return new JPAQueryFactory(em);
	}
}

// Repository에 @RequiredArgsConstructor로 생성자를 주입
@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {

    private final EntityManager em;
    private final JPAQueryFactory queryFactory;
    // ... 코드 생략   
}

 

** 참고

  • Querydsl은 EntityManager를 통해 동작하기 때문에 싱글톤으로 생성하여 여러객체에서 사용하여도 멀티쓰레드환경에서 사용 시 동시성 문제를 걱정하지 않아도됨
  • 스프링이 주입해주는 EntityManager는 실제 동작 시점에 진짜 EntityManager를 찾아주는 프록시용 가짜 엔터티 매니저이며 이 가짜 EntityManager는 실제 사용 시점에 트랜잭션 단위로 실제 EntityManager를(영속성 컨텍스트) 할당해주기 때문에 문제가 없음

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

1) DTO 및 검색 조건 생성

(1) DTO 생성

  • 회원과 팀의 정보를 한번에 담은 DTO 생성
  • @QueryProjection을 이용하여 Q클래스를 생성, gradle - clean, build가 필요하며 DTO를 종속적이지않고 순수하게 유지하고 싶다면 Projection.bean() or fields() or constructor()를 사용하여 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;
    }
}

 

(2) 회원 검색 조건

  • 클래스의 이름은 자유롭게 일관성만 있다면 줄여도 상관없음
  • 회원이름과 팀이름은 String, 나이의 범위 값은 null이 들어올 수도 있기 때문에 Integer로 선언
@Data
public class MemberSearchCondition {
    // 회원명, 팀명, 나이(ageGoe, ageLoe)

    private String username;
    private String teamName;
    private Integer ageGoe;
    private Integer ageLoe;
}

2) 동적쿼리 - Builder 사용

(1) 동적쿼리 작성

  • 빌더를 생성한 후 예외 조건(null, 빈문자열을 통과했을 때 연산되어야할 조건들을 설정
  • select 절에 QMemberTeamDto를 생성하고 각 필드의 값들을 작성하여 조회할 대상을 지정
  • 검색조건들이 담긴 builder 변수를 where의 인수로 입력
// 동적쿼리 - builder 사용
public List<MemberTeamDto> searchByBuilder(MemberSearchCondition condition) {
    BooleanBuilder builder = new BooleanBuilder();

    // StringUtils.hasText()를 이용하면 null, ""(빈문자열)일때 false를 반환함
    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)
            .where(builder)
            .leftJoin(member.team, team)
            .fetch();
}

 

(2) Test

  • 초기 데이터를 입력하고 만들어둔 검색조건 클래스를 생성하여 검색조건의 값들을 입력
  • 검색조건을 통해 조회하는 메서드를 호출하여 결과를 호출하면 정상적으로 테스트가 통과되고 SQL도 조건들이 모두 들어가있는 쿼리가 생성되어있음

** 주의

  • 만약 조건을 모두 제외하고 실행하면 DB의 전체 데이터를 가져오는 쿼리를 날려버리기 때문에 이런 작은 애플리케이션은 문제가 없을지라도 실무에서는 매우 조심해야함
  • 이런 동적쿼리를 짤 때는 웬만하면 기본조건이 있거나 적어도 limit라도 있는것이 좋으며 가급적이면 페이징조건을 입력해서 제한을 두는 것을 권장함
// 동적쿼리 - Builder 사용
@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);

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

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

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

1) 동적쿼리 - Where절 파라미터 사용

  • 위에서 사용한 DTO와 검색조건을 동일하게 사용

(1) 동적쿼리 작성 및 테스트

  • where절에 사용할 조건들을 별도의 메서드로 정의하고, 필요한 검색조건에 따라 해당 메서드를 호출하여 where절에 조건을 추가
  • BooleanBuilder로 작성된 구조에 비해 where절로 조건을 메서드화하여 파라미터로 입력하면 Querydsl 구조가 where절에 어떤 조건이 있는지 명확하게 알 수 있으며, 그 조건의 동작을 상세하게 보려고 할때 메서드를 확인하는 구조로 되어있음
  • 동일한 테스트케이스로 작성된 메서드를 테스트 수행해보면 정상적으로 조건들이 반영된 쿼리가 호출되고 테스트가 통과함
  • 조건을 작성한 메서드의 반환타입은 Predicate보다 Querydsl의 BooleanExpression을 사용하는 것이 좋음
  • 만들어둔 조건들을 조립해서 새로운 메서드로 뽑아서 사용할 수 있고, 메서드들을 재사용할 수 있는 장점이 있어 재사용성과 확장성 측면에서 유리함(물론 예외 처리는 잘 해주어야 함)
// 동적쿼리 - where절 파라미터 사용
public List<MemberTeamDto> search(MemberSearchCondition condition) {
    return queryFactory
            .select(new QMemberTeamDto(
                    member.id,
                    member.username,
                    member.age,
                    team.id,
                    team.name))
            .from(member)
            .where(
                    usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe())
            )
            .leftJoin(member.team, team)
            .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;
}

 


4. 조회 API 컨트롤러 개발

1) 편리한 데이터를 확인하기 위한 설정 추가

  • 테스트와 애플리케이션의 프로파일을 분리하여 실행시 다른 프로파일로 동작하도록 할 수 있음
  • 현재 테스트코드는 별도의 초기화 데이터가 있기 때문에 테스트실행이 아닌 애플리케이션 실행시에는 또 다른 별도의 초기화 데이터를 입력하도록 하기 위함

(1) main 프로파일 설정

  • ~/main/resources/application.yml에 아래 내용을 추가
  • 보통 김영한님은 local 서버는 local, 개발 서버는 dev 이나 develop, 운영은 real 이런 식으로 입력한다고 함
spring:
  profiles:
    active: local

 

(2) test 프로파일 설정

  • ~/test에 resources디렉토리를 생성하고 application.yml파일 생성
  • 기존에 main에 있던 설정을 모두 복사한 뒤에 profiles의 active의 이름만 test로 작성해주면 됨
spring:
  profiles:
    active: test

 

(3) 샘플 데이터 추가하는 클래스 생성

  • @Profile애노테이션을 사용하여 프로파일이 local일 때만 동작하도록하고 컴포넌트스캔 대상이 되도록 @Component 애노테이션을 작성
  • 내부 클래스로 실제 초기화하는 클래스와 메서드를 생성한 뒤 외부클래스에서 내부클래스를 선언한 후 초기화 메서드를 호출하여 데이터를 초기화
  • 내부적으로 동작하는 라이프사이클 때문에 @PostConstruct와 @Transactional을 동시에 사용할 수 없어서 이런 식으로 작성한다고 함
@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));
            }
        }
    }
}

2) API 컨트롤러 개발

(1) 조회 컨트롤러

  • API 컨트롤러이기 때문에 @RestController를 생성한 클래스에 적용
  • 조회용이므로 @GetMapping을 적용하여 만들어둔 컨트롤러의 연산결과를 반환
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);
    }
}

 

(2) 실행결과

  • 애플리케이션 실행 후 Postman으로 매핑된 url로 들어가보면 전체 데이터가 전부 조회됨
  • MemberSearchCondition에 입력된 필드명을 쿼리파라미터로 조건을 입력해주면 조건이 적용되어 응답결과가 반환되는 것을 확인할 수 있음

쿼리 파라미터로 조건의 값들을 입력하면 조건이 적용된 결과가 응답이 옴