스프링데이터 + JPA/스프링 데이터 JPA

28. Projections

sdafdq 2023. 11. 26. 19:16

이번거는 앞의 2개와 달리 때때로 도움이 될 수 있음.

 

일단 Jpa자체가 엔티티를 대상으로 조회하려고 만들어 진 거임.

근데 뭐 예를들어 엔티티 자체가 아니라 엔티티의 값 하나만, Member의 username 하나만 조회하고 싶으면?

 

쉽게 이야기 해서,

Projections 직역은 예상, 계획인데

select () from 저기 select 절 뒤에 들어갈 거라고 보면 된다.

 

사용법도 간단함

public interface UsernameOnly {
    String getUsername();
}

먼저 이렇게 인터페이스를 만듦.

저 메소드명이 중요한데, 가져올 것의 프로퍼티명으로 만들어 버리셈. 당연히 타입은 맞춰주고.

그 다음,

 

List<UsernameOnly> findProjectionsByUsername(String username);

그냥 SpringDataJpa에서 저렇게 반환받는 타입으로 저걸 명시해 주면 됨.

 

그 후 걍 쓰면 됨.

@Test
public void projections(){
    Team teamA = new Team("teamA");
    em.persist(teamA);

    Member m1 = new Member("m1", 0, teamA);
    Member m2 = new Member("m2", 0, teamA);
    em.persist(m1);
    em.persist(m2);

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

    List<UsernameOnly> result = memberRepository.findProjectionsByUsername("m1");

    for (UsernameOnly usernameOnly : result) {
        System.out.println("usernameOnly = " + usernameOnly.getClass());
    }
}

저렇게.

이것의 큰 장점은 내가 저렇게 find..ByUsername 해서 SpringDataJpa를 통해 메소드명으로 쿼리 생성으로 자동으로 만들었듯이, SpringDataJpa 기능들과 같이 쓸 수 있다는 거.

 

저거, 저 우리가 UsernameOnly 인터페이스만 만들어 놨는데, 저거 getClass() 해서 출력해 보면,

usernameOnly = class jdk.proxy2.$Proxy181

뭔가의 프록시임.

이번엔 그냥

usernameOnly만 출력해 보면.

usernameOnly = org.springframework.data.jpa.repository.query.AbstractJpaQuery$TupleConverter$TupleBackedMap@1e7b277a

보면 뭔가 SpringDataJpa가 JpaRepository 상속받아서 인터페이스 만들면 구현체 만들어 주듯이, 이것도 저 UsernameOnly를 스프링 Data Jpa의 무언가의 프록시로 덧씌워서 만들어 줬음.

 

대충 이제 우리가 정의해뒀던 getUsername()해보면 잘 나옴.

 

그리고 이거는, 저렇게 하나만 받을 수 있는게 아니라,

 

public interface MemberForIntroduce {
    String getUsername();
    int getAge();
}

이렇게 여러개로도 가능.

 

이거는 내가 생각보다 진짜 자주 쓸 듯. 그냥 Dto를 이걸로 하면 안되나?

 

이거를 인터페이스 기반의 close projection 이라고 함.

 

반대로 open projection도 있는데,

public interface UsernameOnly {
    @Value("#{target.username + ' ' + target.age}")
    String getUsername();
}

이렇게 spring spel 문법을 쓸 수 있다.

저렇게 spel 문법을 쓰면(@Value 저 애노테이션이 lombok꺼가 아니고 스프링 꺼다) 엔티티는 일단 모두 가져온 다음에, 

저 SpEL을 분석해서 가져온다.

target이 가져온 값, 엔티티가 될거고, 

그러니까 저 getUsername() 안에 저 @Value, 우리가 테스트를 Member("m1", 0) 이렇게 했으니까 m1 0 이렇게 들어갈 것이다.

 

@Test
public void projections(){
    Team teamA = new Team("teamA");
    em.persist(teamA);

    Member m1 = new Member("m1", 0, teamA);
    Member m2 = new Member("m2", 0, teamA);
    em.persist(m1);
    em.persist(m2);

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

    List<UsernameOnly> result = memberRepository.findProjectionsByUsername("m1");

    for (UsernameOnly usernameOnly : result) {
        System.out.println("usernameOnly = " + usernameOnly.getUsername());
    }
}
select
    m1_0.member_id,
    m1_0.age,
    m1_0.create_by,
    m1_0.created_date,
    m1_0.last_modified_by,
    m1_0.last_modified_date,
    m1_0.team_id,
    m1_0.username 
from
    member m1_0 
where
    m1_0.username=?
usernameOnly = m1 0

 

이렇게 SpEL을 이용해서 일단 엔티티는 다 가져오고, SpEL을 분석해서 저 프로퍼티를 요청하면 줄 값으로 넣어준다.

 

다음은 이제 그냥 클래스 기반으로 해볼 것.

@Getter
public class UsernameOnlyDto {
    private final String username;

    public UsernameOnlyDto(String username) {
        this.username = username;
    }
}
List<UsernameOnlyDto> findProjectionsDtoByUsername(String username);
@Test
public void projections(){
    Team teamA = new Team("teamA");
    em.persist(teamA);

    Member m1 = new Member("m1", 0, teamA);
    Member m2 = new Member("m2", 0, teamA);
    em.persist(m1);
    em.persist(m2);

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

    List<UsernameOnlyDto> result = memberRepository.findProjectionsDtoByUsername("m1");

    for (UsernameOnlyDto usernameOnly : result) {
        System.out.println("usernameOnly = " + usernameOnly.getUsername());
    }
}

이거 좋다.

select
    m1_0.username 
from
    member m1_0 
where
    m1_0.username=?

쿼리도 딱 저렇게 나가고,

usernameOnly = m1

결과도 잘 나온다.

 

여기서 중요한 건,

 

@Getter
public class UsernameOnlyDto {
    private final String username;

    public UsernameOnlyDto(String username) {
        this.username = username;
    }
}

여기서 생성자와 파라미터 명이다.

저 파라미터 명을 보고 분석한다.

정확히 테이블의 필드명과 맞아야 한다.

 

getClass()도 해봤는데, 내가 구체적인 클래스를 명시했기 때문에 프록시일 필요도 없고, 그냥 UsernameDto였다.

 

동적 프로젝션 이란 것도 있다.

 

이거는 여러 종류의 Dto 처리할 때 좋아 보인다.

왜 우리가 createQuery해서 타입 명시했을 때 처럼

<T> List<T> findDynamicProjectionsByUsername(@Param("username") String username, Class<T> type);

일단 SpringDataJpa에 이렇게 선언해주고,

 

@Test
public void projections(){
    Team teamA = new Team("teamA");
    em.persist(teamA);

    Member m1 = new Member("m1", 0, teamA);
    Member m2 = new Member("m2", 0, teamA);
    em.persist(m1);
    em.persist(m2);

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

    List<UsernameOnlyDto> result = memberRepository.findDynamicProjectionsByUsername("m1", UsernameOnlyDto.class);

    for (UsernameOnlyDto usernameOnly : result) {
        System.out.println("usernameOnly = " + usernameOnly.getUsername());
    }
}

이렇게 return타입을 받고 싶은거, 인자로 받고싶은 타입을 추가로 넣어주면 된다.

잘 된다.

select
    m1_0.username 
from
    member m1_0 
where
    m1_0.username=?
usernameOnly = m1

 

 

아까 했던데로 저기다가 인터페이스로 해도 되고, 아니면 지금처럼 클래스 따로 만들어서 해도 되고.

 

인터페이스는 프로퍼티 get메소드 정의시키면 알아서 Spring Data Jpa가 프록시로 구현체를 만들어 주고,

클래스는 생성자에 인자이름이 중요하고. 인터페이스는 get프로퍼티이름이 중요하고.

 

또 저렇게 동적 프로젝션으로 여러 타입을 받을 수도 있고.

 

이번엔 중첩구조,

그러니까 join? Member 뿐만 아니라 연관관계, Team까지 같이 가져오도록

 

먼저 인터페이스로

public interface NestedClosedProjections {
    String getUsername();
    TeamInfo getTeam();
    interface TeamInfo{
        String getName();
    }
}

이렇게 만듦.

Team의 인터페이스명은 저 MemberDto의 인터페이스명과 마찬가지로 아무 상관이 없음.

 

get프로퍼티 이름이 중요함.

 

그리고 꼭 저렇게 인터페이스를 인터페이스 안에 넣을 필요는 없음.

그냥 Team 엔티티로 받아와도 되긴 함.

 

여튼 그래도 Dto니까, 저렇게 정말 간단하게 필요한 것만 모아놓은 (이번 같은 경우는 Team의 name만) 인터페이스 구조를 만듦 (확실히 인터페이스 구조도 저렇게 중첩으로 해 놓는게 여기 한 곳에서 관리하기 쉬울 듯.)

 

그 후

@Test
public void projections(){
    Team teamA = new Team("teamA");
    em.persist(teamA);

    Member m1 = new Member("m1", 0, teamA);
    Member m2 = new Member("m2", 0, teamA);
    em.persist(m1);
    em.persist(m2);

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

    List<NestedClosedProjections> result = memberRepository.findDynamicProjectionsByUsername("m1", NestedClosedProjections.class);

    for (NestedClosedProjections usernameOnly : result) {
        System.out.println("usernameOnly = " + usernameOnly.getUsername());
        System.out.println("usernameOnly = " + usernameOnly.getTeam().getName());
    }
}

이렇게 똑같이 쿼리를 날려보면

 

select
    m1_0.username,
    t1_0.team_id,
    t1_0.create_date,
    t1_0.name,
    t1_0.updated_date 
from
    member m1_0 
left join
    team t1_0 
        on t1_0.team_id=m1_0.team_id 
where
    m1_0.username=?
usernameOnly = m1
usernameOnly = teamA

결과는 잘 나옴.

근데 언짢은 부분이 있을거임.

맞음. 이 프로젝션은 저렇게 연관관계, 중첩구조의 경우에는 최적화가 안됨.

 

그러니까 root 엔티티는 최적화가 되는데, 같이 가져오는 건 그냥 싹다 끌어다 옴.

 

root 엔티티 하나만 Dto로 해서 가져올 생각이면 괜찮은 듯.

 

근데 만약 프로젝션 대상이 root가 아니라면 일단은 left join으로 가져옴. 그래서 데이터 못 가져올 걱정은 없음.

그 다음 애플리케이션에서 조립함.

 

 

프로젝션 대상이 하나만 가져올 경우 유용함.

root 엔티티를 넘어갈 경우 쿼리 최적화가 안됨

QueryDSL에 따로 프로젝션 기능이 있음. 그걸 사용하면 그건 최적화 됨.

 

QueryDSL 오늘도 1승