seungriyou / spring-study

자바 스프링 부트를 배워봅시다 🔥
0 stars 0 forks source link

[강의 정리] 07. 실전! Querydsl #15

Open seungriyou opened 8 months ago

seungriyou commented 8 months ago

실전! Querydsl

Contents


H2 URL

jdbc:h2:tcp://localhost/~/workspace/spring-study/spring-study/07-querydsl/querydsl/querydsl
seungriyou commented 8 months ago

1. 프로젝트 환경설정

image

lombok 설정

// test lombok 사용
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'


Querydsl 설정과 검증

Q 파일 생성 확인


[!caution] Q 파일은 컴파일 시점에 자동 생성되므로 git에 포함하면 안 된다. 수업에서는 gradle build/ 디렉토리 밑에 넣어서 자연스럽게 해결되도록 했다. (주로 build 폴더를 gitignore)

seungriyou commented 8 months ago

2. 예제 도메인 모델

seungriyou commented 8 months ago

3. 기본 문법

JPQL vs. Querydsl

Querydsl 장점

  1. 컴파일 시점에 오류를 발견할 수 있다.
  2. 파라미터 바인딩을 자동으로 해결해준다.


동시성 문제는?***

다음과 같이 JPAQueryFactory를 필드로 사용하면 동시성 문제가 발생하지는 않을까?

@SpringBootTest
@Transactional
public class QuerydslBasicTest {

    @Autowired
    EntityManager em;

    JPAQueryFactory queryFactory;

    // 테스트 케이스 추가
    @BeforeEach
    public void before() {
        queryFactory = new JPAQueryFactory(em);

동시성 문제는 JPAQueryFactory를 생성할 때 제공하는 EntityManager(em)에 달려있는데, 스프링 프레임워크는 여러 스레드에서 동시에 같은 EntityManager에 접근해도, 트랜잭션마다 별도의 영속성 컨텍스트를 제공한다.

따라서 멀티 스레드 환경에서의 동시성 문제는 걱정하지 않아도 된다.


기본 Q-Type 활용

Q 클래스 인스턴스를 사용하는 두 가지 방법

  1. JPQL의 별칭 직접 지정

    같은 테이블을 조인해야 하는 경우에만, 이름이 같으면 안 되기 때문에 alias를 직접 지정하는 방법을 사용하는 것을 권장한다.

    QMember qMember = new QMember("m");
  2. 기본 인스턴스 사용

    QMember qMember = QMember.member;

    static import로 사용하면 더 편리하다. (→ 권장 ✅)

    import static study.querydsl.entity.QMember.member;
    
    Member findMember = queryFactory
                .select(member)
                .from(member)
                .where(member.username.eq("member1"))
                .fetchOne();


[!note] Querydsl은 결국 JPQL의 빌더이다.

→ 생성되는 JPQL을 보고 싶다면, application.yml에 다음을 추가하자.

spring.jpa.properties.hibernate.use_sql_comments: true


검색 조건 쿼리

select, fromselectFrom으로 합칠 수 있다.

  • .and(), .or()를 메서드 체인으로 연결할 수 있다.
  • 검색 조건들
```java
member.username.eq("member1") // username = 'member1'
member.username.ne("member1") //username != 'member1'
member.username.eq("member1").not() // username != 'member1'

member.username.isNotNull() //이름이 is not null

member.age.in(10, 20) // age in (10,20)
member.age.notIn(10, 20) // age not in (10, 20)
member.age.between(10,30) //between 10, 30

member.age.goe(30) // age >= 30
member.age.gt(30) // age > 30
member.age.loe(30) // age <= 30
member.age.lt(30) // age < 30

member.username.like("member%") //like 검색 
member.username.contains("member") // like ‘%member%’ 검색 
member.username.startsWith("member") //like ‘member%’ 검색 
...
```


결과 조회


[!note] JPQL에서 엔티티를 직접 지정하면 id로 바뀐다!


정렬


페이징

List<Member> result = queryFactory
            .selectFrom(member)
            .orderBy(member.username.desc())
            .offset(1)
            .limit(2)
            .fetch();


집합

groupBy, having도 사용 가능하다.


조인

기본 조인

join(조인 대상, 별칭으로 사용할 Q타입)


세타 조인

연관관계가 없어도 수행하는 조인

join(조인 대상 엔티티1, 조인 대상 엔티티2...)

→ 외부 조인이 불가능하다! 하지만 조인 on을 사용하면 외부 조인이 가능하다.


on

JPA 2.1 부터 지원

  1. 조인 대상 필터링
```java
@Test
public void join_on_filtering() {
    /**
     * 회원과 팀을 조회하면서,
     * 팀 이름이 teamA인 팀만 조인, 회원은 모두 조회
     * JPQL: select m, t from Member m left join m.team t on t.name = 'teamA'
     */
    List<Tuple> result = queryFactory
            .select(member, team)
            .from(member)
            .leftJoin(member.team, team).on(team.name.eq("teamA"))
            .fetch();

    for (Tuple tuple : result) {
        System.out.println("tuple = " + tuple);
    }
}
```

```java
tuple = [Member(id=1, username=member1, age=10), Team(id=1, name=teamA)]
tuple = [Member(id=2, username=member2, age=20), Team(id=1, name=teamA)]
tuple = [Member(id=3, username=member3, age=30), null]
tuple = [Member(id=4, username=member4, age=40), null]
```

on 절을 외부 조인(ex. leftJoin())이 아닌 내부 조인(join())과 함께 사용한다면, where 절에서 필터링 하는 것과 기능이 동일하다. 따라서 내부 조인이면 더 가독성이 좋은 where 절로 해결하고, 정말 필요한 경우에만 on을 사용하자.

  1. 연관관계 없는 엔티티 외부 조인

    → 세타 조인은 내부 조인만 가능했다!

    @Test
    public void join_on_no_relation() {
        /**
         * 회원의 이름이 팀 이름과 같은 대상 외부 조인
         */
        em.persist(new Member("teamA"));
        em.persist(new Member("teamB"));
        em.persist(new Member("teamC"));
    
        List<Tuple> result = queryFactory
                .select(member, team)
                .from(member) // 그냥 세타 조인에서는 외부 조인 불가!
                .leftJoin(team).on(member.username.eq(team.name))
                .fetch();
    
        for (Tuple tuple : result) {
            System.out.println("tuple = " + tuple);
        }
    }
    tuple = [Member(id=1, username=member1, age=10), null]
    tuple = [Member(id=2, username=member2, age=20), null]
    tuple = [Member(id=3, username=member3, age=30), null]
    tuple = [Member(id=4, username=member4, age=40), null]
    tuple = [Member(id=5, username=teamA, age=0), Team(id=1, name=teamA)]
    tuple = [Member(id=6, username=teamB, age=0), Team(id=2, name=teamB)]
    tuple = [Member(id=7, username=teamC, age=0), null]

[!caution]
leftJoin(member.team, team)leftJoin(team).on(...)의 차이점

  • leftJoin(member.team, team): 조인할 때, 동일 id 조건이 추가된다. (SQL에서 동일 id 찾는 로직 추가)

    내부적으로 PK - FK를 사용한다.

    • leftJoin(team).on(...): on 절의 조건으로만 조인된다.

      on 절을 사용해서 서로 관계 없는 필드로 외부 조인하는 경우에는, leftJoin() 부분에 일반 조인과는 다르게 엔티티 하나만 들어간다!


페치 조인

조인 뒤에 fetchJoin()만 추가하면 된다.

@PersistenceUnit
EntityManagerFactory emf;

// (1) fetch join이 없을 때
@Test
public void fetchJoinNo() {
    em.flush();
    em.clear();

    Member findMember = queryFactory
            .selectFrom(member)
            .where(member.username.eq("member1"))
            .fetchOne();

    // 초기화 되었는지 되지 않았는지 확인
    boolean loaded = emf.getPersistenceUnitUtil().isLoaded(findMember.getTeam());
    assertThat(loaded).as("페치 조인 미적용").isFalse();
}

// (2) fetch join 적용
@Test
public void fetchJoinYes() {
    em.flush();
    em.clear();

    Member findMember = queryFactory
            .selectFrom(member)
            .join(member.team, team).fetchJoin()    // 뒤에 fetchJoin()만 붙여주면 된다!
            .where(member.username.eq("member1"))
            .fetchOne();

    // 초기화 되었는지 되지 않았는지 확인
    boolean loaded = emf.getPersistenceUnitUtil().isLoaded(findMember.getTeam());
    assertThat(loaded).as("페치 조인 적용").isTrue();
}
/* select
    member1 
from
    Member member1   
inner join

fetch
    member1.team as team 
where
    member1.username = ?1 */
select
    m1_0.member_id,
    m1_0.age,
    t1_0.id,
    t1_0.name,
    m1_0.username 
from
    member m1_0 
join
    team t1_0 
        on t1_0.id=m1_0.team_id 
where
    m1_0.username=?


서브 쿼리

com.querydsl.jpa.JPAExpressions 를 사용한다.

static import를 하면 더 편리하다.

  • eq
```java
@Test
public void subQuery() {
    /**
     * 나이가 가장 많은 회원 조회
     */
    // alias를 다르게 부여하기 위해서 (충돌 방지)
    QMember memberSub = new QMember("memberSub");

    List<Member> result = queryFactory
            .selectFrom(member)
            .where(member.age.eq(
                    select(memberSub.age.max())
                            .from(memberSub)
            ))
            .fetch();

    assertThat(result)
            .extracting("age")
            .containsExactly(40);
}
```


Case 문


[!note] 꼭 필요한지 생각해봐야 한다. 이런 문제들은 보통 DB에서 처리하지 않는다. DB에서는 최소한의 필터링, 그루핑 등 데이터를 줄이는 일을 해야 하며, 데이터를 변환하는 동작은 어플리케이션/프레젠테이션 레이어에서 로직을 작성해야 한다.


상수, 문자 더하기

seungriyou commented 8 months ago

4. 중급 문법

프로젝션과 결과 반환 - 기본, 튜플

프로젝션: select 대상 지정

→ 원하는 필드만 찍어서 가져오는 SQL 최적화!

프로젝션 대상이 하나일 때

프로젝션 대상이 하나이면 타입을 명확하게 지정할 수 있다.

List<String> result = queryFactory
        .select(member.username)
        .from(member)
        .fetch();

하지만 프로젝션 대상이 둘 이상이면 튜플이나 DTO로 조회해야 한다.


튜플 조회

List<Tuple> result = queryFactory
        .select(member.username, member.age)
        .from(member)
        .fetch();

for (Tuple tuple : result) {
    String username = tuple.get(member.username);
    System.out.println("username = " + username);
    Integer age = tuple.get(member.age);
    System.out.println("age = " + age);
}

[!caution] 하지만 리포지토리 계층을 넘어서서 서비스 계층, 컨트롤러까지 넘어가는 것은 좋은 설계가 아니다! 하부 구현 기술이 해당 계층까지 넘어가면 안 된다. (튜플은 querydsl에 종속적!)

다른 계층에 나갈 때는 DTO 권장!


프로젝션과 결과 반환 - DTO 조회

JPQL에서는?

패키지명 전체를 다 적어야 하고 new 명령어로 생성한다.

생성자 방식만 지원한다.

List<MemberDto> result = em.createQuery("select new study.querydsl.dto.MemberDto(m.username, m.age) from Member m", MemberDto.class)
        .getResultList();


Querydsl에서는? (빈 생성, Bean population)

결과를 DTO로 반환하는 세 가지 방법

  1. 프로퍼티 접근
  2. 필드 직접 접근
  3. 생성자 사용

[!note] DTO 클래스에 default 생성자를 만들어주거나, **@NoArgsConstructor**를 달아주어야 한다.

→ 일반 DTO를 생성하고 set을 해야하므로!

  1. 프로퍼티 접근 (setter)

    List<MemberDto> result = queryFactory
            .select(Projections.bean(MemberDto.class,
                    member.username,
                    member.age))
            .from(member)
            .fetch();
  2. 필드 직접 접근 (getter/setter 무시하고 바로 필드에)

    List<MemberDto> result = queryFactory
            .select(Projections.fields(MemberDto.class,
                    member.username,
                    member.age))
            .from(member)
            .fetch();
  3. 생성자 사용 (생성자의 파라미터 순서와 동일하게 들어가야 함)

    List<MemberDto> result = queryFactory
            .select(Projections.constructor(MemberDto.class,
                    member.username,
                    member.age))
            .from(member)
            .fetch();


별칭 적용하기 (alias)

QMember memberSub = new QMember("memberSub");

List<UserDto> result = queryFactory
        .select(Projections.fields(UserDto.class,
                // 필드
                member.username.as("name"),
                // 서브 쿼리
                ExpressionUtils.as(JPAExpressions
                                .select(memberSub.age.max())
                                .from(memberSub),
                        "age")))
        .from(member)
        .fetch();
  1. 필드에 별칭 적용: .as("...")

    DTO의 필드 이름이 다른 경우

    • UserDtousername 대신 name 필드
    ```java
    @Data
    public class UserDto {
    
        private String name;
        private int age;
    }
    ```
    • DTO와 엔티티의 필드 이름이 맞지 않으면 다음과 같이 null로 들어간다.

      userDto = UserDto(name=null, age=10)
      userDto = UserDto(name=null, age=20)
      userDto = UserDto(name=null, age=30)
    • 따라서, 해당 필드에 as()를 이용하여 필드에 별칭을 지정해주어야 한다.

  2. 필드나 서브 쿼리에 별칭 적용: ExpressionUtils.as(source, alias)
    • 필드는 1번 방법이 더 간단하나, 서브 쿼리는 이렇게 밖에 못 한다!


프로젝션과 결과 반환 - @QueryProjection


distinct

List<String> result = queryFactory
         .select(member.username).distinct()
         .from(member)
         .fetch();


동적 쿼리

동적 쿼리 해결하는 두 가지 방법

  1. BooleanBuilder
  2. where 다중 파라미터 사용

[1] BooleanBuilder

@Test
public void dynamicQuery_BooleanBuilder() {
    String usernameParam = "member1";
    Integer ageParam = null;

    List<Member> result = searchMember1(usernameParam, ageParam);
    assertThat(result.size()).isEqualTo(1);

}

private List<Member> searchMember1(String usernameCond, Integer ageCond) {
    BooleanBuilder builder = new BooleanBuilder();
    if (usernameCond != null) {
        builder.and(member.username.eq(usernameCond));
    }
    if (ageCond != null) {
        builder.and(member.age.eq(ageCond));
    }

    return queryFactory
            .selectFrom(member)
            .where(builder)
            .fetch();
}


[2] where 다중 파라미터 사용 (✅ 권장!)

private List<Member> searchMember2(String usernameCond, Integer ageCond) {
    return queryFactory
            .selectFrom(member)
            .where(usernameEq(usernameCond), ageEq(ageCond))
            .fetch();
}

private BooleanExpression ageEq(Integer ageCond) {
    return ageCond != null ? member.age.eq(ageCond) : null;
}

private BooleanExpression usernameEq(String usernameCond) {
    return usernameCond != null ? member.username.eq(usernameCond) : null;
}


수정, 삭제 벌크 연산

변경 감지는 한 건씩이다. 벌크 연산은 어떻게 할까?

[!caution] 벌크 연산을 실행하고 나면 영속성 컨텍스트를 초기화해주는 것을 권장한다.


SQL Function 호출

dialect에 등록된 내용만 호출할 수 있다.

  • member → M으로 변경하는 replace 함수
```java
@Test
public void sqlFunction() {
    List<String> result = queryFactory
            .select(Expressions.stringTemplate(
                    "function('replace', {0}, {1}, {2})",
                    member.username, "member", "M"
            ))
            .from(member)
            .fetch();

    for (String s : result) {
        System.out.println("s = " + s);
    }

}
```
seungriyou commented 8 months ago

5. 실무 활용 - 순수 JPA와 Querydsl

리포지토리 - JPA → QueryDSL

리포지토리에서 JPAQueryFactory 사용하는 두 가지 방법**

선호에 따라 선택

  1. 리포지토리의 생성자에서 생성하기 (new JPAQueryFactory(em))

주입 받는 것이 하나라서 테스트 코드 작성 시 편리하다.

```java
@Repository
public class MemberJpaRepository {

    private final EntityManager em;
    private final JPAQueryFactory queryFactory;

    public MemberJpaRepository(EntityManager em) {
        this.em = em;
        this.queryFactory = new JPAQueryFactory(em);
    }
```
  1. 스프링 빈으로 등록해서 주입 받아 사용하기

    코드를 줄일 수 있으나, 테스트 코드 작성 시 주입 받아야 할 것이 두 개라 번거롭다.

    • QuerydslApplication.java
    ```java
    @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 {
      
          private final EntityManager em;
          private final JPAQueryFactory queryFactory;
      
          public MemberJpaRepository(EntityManager em, JPAQueryFactory queryFactory) {
              this.em = em;
              this.queryFactory = queryFactory;
          }

      다음과 같이 생성자 생략하고 @RequiredArgsConstructor를 사용해도 된다.

      @Repository
      @RequiredArgsConstructor
      public class MemberJpaRepository {
      
          private final EntityManager em;
          private final JPAQueryFactory queryFactory;


[!note] 스프링 빈으로 등록해서 사용하면 동시성 문제가 발생하지는 않을까?

발생하지 않는다. 스프링이 주입해주는 엔티티 매니저는 실제 동작 시점에 진짜 엔티티 매니저를 찾아주는 프록시용 가짜 엔티티 매니저이다.

이는 실제 사용 시점에 트랜잭션 단위로 실제 엔티티 매니저(→ 영속성 컨텍스트)를 할당해준다.


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


동적 쿼리와 성능 최적화 조회 (ex. 검색 조건)

[!note] 성능 최적화 = 한 번에 DTO로 딱 조회하기

다음 예제에서는 @QueryProjection을 사용하는데, 이렇게 하면 DTO가 Querydsl을 의존하게 된다. 이를 방지하려면 Projection.bean(), fields(), constructor()를 사용하면 된다.

DTO


[1] Builder 사용

[!tip] StringUtils.hasText(): null도, ""도 아닌지 확인한다.

[!caution] builder 조건이 null이게 되면 전체 데이터를 불러오므로, 웬만하면 기본 조건이 있거나 리미트가 있을 때 이러한 동적 쿼리를 작성하는 것을 권장한다.


[2] Where 절 파라미터 사용

[!warning] Predicate 보다 BooleanExpression 타입을 사용하도록 하자.

public List<MemberTeamDto> search(MemberSearchCondition condition) {
    return queryFactory
            .select(new QMemberTeamDto(
                    member.id,  // 생성자를 사용하므로, 필드 이름 맞출 필요 X
                    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 ageLoe(Integer ageLoe) {
    return ageLoe != null ? member.age.loe(ageLoe) : null;
}

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

private BooleanExpression teamNameEq(String teamName) {
    return hasText(teamName) ? team.name.eq(teamName) : null;
}

private BooleanExpression usernameEq(String username) {
    return hasText(username) ? member.username.eq(username) : null;
}


조회 API 컨트롤러 개발

리포지토리에 컨트롤러 추가 개발

샘플 데이터 (w/ 프로파일 설정)

샘플 데이터가 테스트 코드에 실행 시에는 추가되지 않고, 어플리케이션 실행 시에만 추가되도록 프로파일을 나눈다.

  1. 프로파일 설정 (main/resources/application.yml)

주로 로컬 개발 환경은 local, 개발 서버는 dev / develop, 운영은 real로 둔다.

```yaml
spring:
  profiles:
    active: local
```
  1. 동일한 파일을 test/resources 디렉토리를 만들어서 복사 붙여넣기 하고, 프로파일 부분을 test로 변경한다.

    spring:
      profiles:
        active: test
  2. 컨트롤러 패키지에 다음과 같은 코드를 작성한다.

    InitMemberService.init() 메서드 안에 있는 내용을 곧바로 init()에 넣으면 안 되는 이유는, 스프링 라이프 사이클로 인해 @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));
                }
            }
        }
    }


컨트롤러 (v1)

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberJpaRepository memberJpaRepository;

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

포스트맨에서 다음과 같이 url 작성하면 확인 가능

http://localhost:8080/v1/members?teamName=teamB&ageGoe=31&ageLoe=35&username=member31
seungriyou commented 8 months ago

6. 실무 활용 - 스프링 데이터 JPA와 Querydsl

(정적 쿼리) JPA → 스프링 데이터 JPA 리포지토리로 변경

인터페이스 추가!

public interface MemberRepository extends JpaRepository<Member, Long> {
    // select m from Member m where m.username = ?
    List<Member> findByUsername(String username);
}


사용자 정의 리포지토리

스프링 데이터 JPA는 인터페이스로 동작하므로, Querydsl을 함께 사용하려면 사용자 정의 리포지토리를 만들어야 한다.

사용자 정의 리포지토리 구성


사용자 정의 리포지토리 사용법

  1. 사용자 정의 인터페이스 작성

    public interface MemberRepositoryCustom {
        List<MemberTeamDto> search(MemberSearchCondition condition);
    }
  2. 사용자 정의 인터페이스 구현

    이름: 스프링 데이터 인터페이스 이름 + Impl

    public class MemberRepositoryImpl implements MemberRepositoryCustom {
    
        // for Querydsl
        private final JPAQueryFactory queryFactory;
    
        public MemberRepositoryImpl(EntityManager em) {
            this.queryFactory = new JPAQueryFactory(em);
        }
    
        @Override
        public List<MemberTeamDto> search(MemberSearchCondition condition) {
            return queryFactory
                    .select(new QMemberTeamDto(
                            member.id,  // 생성자를 사용하므로, 필드 이름 맞출 필요 X
                            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 ageLoe(Integer ageLoe) {
            return ageLoe != null ? member.age.loe(ageLoe) : null;
        }
    
        private BooleanExpression ageGoe(Integer ageGoe) {
            return ageGoe != null ? member.age.goe(ageGoe) : null;
        }
    
        private BooleanExpression teamNameEq(String teamName) {
            return hasText(teamName) ? team.name.eq(teamName) : null;
        }
    
        private BooleanExpression usernameEq(String username) {
            return hasText(username) ? member.username.eq(username) : null;
        }
    }
  3. 스프링 데이터 리포지토리에 사용자 정의 인터페이스 상속

    public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
        // select m from Member m where m.username = ?
        List<Member> findByUsername(String username);
    }


[!tip] 기본은 커스텀 리포지토리이지만, 더 유연한 설계를 원한다면 분리해도 좋다!

즉, 반드시 사용자 정의 리포지토리에 매몰될 필요는 없다. 모든 것을 커스텀에 넣는 것도 좋은 설계는 아니다!

특정 화면이나 조회 API 등에 특화된 기능이라면, 그냥 리포지토리를 분리(ex. MemberQueryRepository)하는 편이 좋을 수도 있다.

기존 리포지토리에서는 엔티티를 많이 검색하고 재사용이 많이 되는 기능이 존재한다면, 이처럼 특화된 기능들은 수정 라이프 사이클이 화면이나 API에 맞춰지기 때문이다.

필요한 곳에서 해당 리포지토리를 주입 받아 사용하면 된다.


스프링 데이터 페이징 활용 (Page, Pageable) (v2)

QueryDSL 페이징 연동 + CountQuery 최적화

  1. 단순한 버전: fetchResults()

    스프링 부트 2.6 이상, Querydsl 5.0 이상에서는 deprecated 이므로 다루지 X

  2. 복잡한 버전: content 쿼리(w/ fetch())와 count 쿼리 분리

    📌 count 쿼리는 최적화(ex. 조인이 필요없으면 제외)할 수 있으면 해야 한다. 하지만 의미 없는 데에 노력할 필요는 없다. (데이터가 정말 많을 때에만)

    • 커스텀 리포지토리 인터페이스

      public interface MemberRepositoryCustom {
          ...
      
          // count 쿼리를 별도로 (-> fetchResults(), fetchCount() deprecated 되었으므로)
          Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable);
      }
    • 커스텀 리포지토리 구현 클래스

      count 쿼리 최적화 (return 부분)

      : count 쿼리가 생략될 수 있는 경우, 생략해서 처리해준다. 필요할 때만!

      • 페이지 시작이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때
      • 마지막 페이지일 때 (offset + 컨텐츠 사이즈를 더해 전체 사이즈를 구함)
      public class MemberRepositoryImpl implements MemberRepositoryCustom {
      
          // for Querydsl
          private final JPAQueryFactory queryFactory;
      
          public MemberRepositoryImpl(EntityManager em) {
              this.queryFactory = new JPAQueryFactory(em);
          }
      
          ...
      
          @Override
          public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
              // content 쿼리
              List<MemberTeamDto> content = 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())
                      )
                      .offset(pageable.getOffset())
                      .limit(pageable.getPageSize())
                      .fetch();
      
              // count 쿼리
              JPAQuery<Long> countQuery = queryFactory
                      // .select(Wildcard.count) // select count(*)
                      .select(member.count()) // select count(member.id)
                      .from(member)
                      .leftJoin(member.team, team)
                      .where(
                              usernameEq(condition.getUsername()),
                              teamNameEq(condition.getTeamName()),
                              ageGoe(condition.getAgeGoe()),
                              ageLoe(condition.getAgeLoe())
                      );
      
              return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
          }
      }


컨트롤러 개발

@GetMapping("/v2/members")
public Page<MemberTeamDto> searchMemberV2(MemberSearchCondition condition, Pageable pageable) {
    // Pageable 인터페이스에 데이터가 바인딩되어서 넘어온다.
    return memberRepository.searchPageComplex(condition, pageable);
}


스프링 데이터 JPA 정렬(Sort)

스프링 데이터 JPA 정렬 (Sort) → Querydsl 정렬 (OrderSpecifier)

변환하는 방법이 있으나, 조건이 조금만 복잡해져도 PageableSort 기능을 사용하기 어려우므로, 동적 정렬 기능이 필요하면 파라미터를 직접 받아 처리하는 것을 권장한다.

seungriyou commented 8 months ago

7. 스프링 데이터 JPA가 제공하는 Querydsl 기능

여기에서 소개하는 기능은 제약이 커서 복잡한 실무 환경에서 사용하기에는 부족하다.


인터페이스 지원: QuerydslPredicateExecutor

사용 방법

public interface MemberRepository extends JpaRepository<Member, Long>, QuerydslPredicateExecutor<Member> {


코드

QMember member = QMember.member;
// 곧바로 querydsl 조건을 넣을 수 있다.
Iterable<Member> result = memberRepository.findAll(
        member.age.between(10, 40)
                .and(member.username.eq("member1"))
);


한계점

  1. 묵시적 조인은 가능하지만 left join이 불가능하다.
  2. 클라이언트가 querydsl에 의존해야 한다. 서비스 클래스가 querydsl이라는 구현 기술에 의존해야 한다.
  3. 복잡한 실무환경에서 사용하기에는 한계가 명확하다.

단순한 경우(ex. 테이블 하나)에는 사용할만 하지만, 결국엔 DTO나 파라미터를 넘겨서 해결하게 될 것이다.


Querydsl Web 지원

쓰지 말자…


리포지토리 지원: QuerydslRepositorySupport

장점


한계


Querydsl 지원 클래스 직접 만들기 (advanced) (v3)

QuerydslRepositorySupport의 한계를 극복하기 위해 직접 만들어보기

장점


QuerydslRepositorySupport (추상 클래스)

@Repository
public abstract class QuerydslRepositorySupport {

    private final Class domainClass;
    private Querydsl querydsl;
    private EntityManager entityManager;
    private JPAQueryFactory queryFactory;

    public QuerydslRepositorySupport(Class<?> domainClass) {
        Assert.notNull(domainClass, "Domain class must not be null!");
        this.domainClass = domainClass;
    }

    @Autowired
    public void setEntityManager(EntityManager entityManager) {
        Assert.notNull(entityManager, "EntityManager must not be null!");
        JpaEntityInformation entityInformation =
                JpaEntityInformationSupport.getEntityInformation(domainClass, entityManager);
        SimpleEntityPathResolver resolver = SimpleEntityPathResolver.INSTANCE;
        EntityPath path = resolver.createPath(entityInformation.getJavaType());
        this.entityManager = entityManager;
        this.querydsl = new Querydsl(entityManager, new
                PathBuilder<>(path.getType(), path.getMetadata()));
        this.queryFactory = new JPAQueryFactory(entityManager);
    }

    @PostConstruct
    public void validate() {
        Assert.notNull(entityManager, "EntityManager must not be null!");
        Assert.notNull(querydsl, "Querydsl must not be null!");
        Assert.notNull(queryFactory, "QueryFactory must not be null!");
    }

    protected JPAQueryFactory getQueryFactory() {
        return queryFactory;
    }

    protected Querydsl getQuerydsl() {
        return querydsl;
    }

    protected EntityManager getEntityManager() {
        return entityManager;
    }

    protected <T> JPAQuery<T> select(Expression<T> expr) {
        return getQueryFactory().select(expr);
    }

    protected <T> JPAQuery<T> selectFrom(EntityPath<T> from) {
        return getQueryFactory().selectFrom(from);
    }

    protected <T> Page<T> applyPagination(Pageable pageable, Function<JPAQueryFactory, JPAQuery> contentQuery, Function<JPAQueryFactory, JPAQuery> countQuery) {
        JPAQuery jpaContentQuery = contentQuery.apply(getQueryFactory());
        List<T> content = getQuerydsl().applyPagination(pageable, jpaContentQuery).fetch();
        JPAQuery<Long> countResult = countQuery.apply(getQueryFactory());
        return PageableExecutionUtils.getPage(content, pageable, countResult::fetchOne);
    }

}


MemberTestRepository (실제 리포지토리)

[!note] applyPagination 주목!

→ content 쿼리와 count 쿼리를 따로 작성하는 방법으로만 진행했다. (deprecated 된 것 때문)

→ v2 코드를 더욱 간단하게 작성했다.

@Repository
public class MemberTestRepository extends QuerydslRepositorySupport {

    public MemberTestRepository() {
        super(Member.class);
    }

    public List<Member> basicSelect() {
        return select(member)
                .from(member)
                .fetch();
    }

    public List<Member> basicSelectFrom() {
        return selectFrom(member)
                .fetch();
    }

    public Page<MemberTeamDto> applyPagination(MemberSearchCondition condition, Pageable pageable) {
        // content 쿼리와 count 쿼리를 나누어서 실행 (w/ 람다)
        return applyPagination(pageable, contentQuery -> contentQuery
                .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())
                ), countQuery -> countQuery
                .select(member.count())
                .from(member)
                .leftJoin(member.team, team)
                .where(
                        usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                ));
    }

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

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

    private BooleanExpression teamNameEq(String teamName) {
        return hasText(teamName) ? team.name.eq(teamName) : null;
    }

    private BooleanExpression usernameEq(String username) {
        return hasText(username) ? member.username.eq(username) : null;
    }
}