본문 바로가기
Spring|Spring-boot/Spring-Data-JPA

Projections

by oncerun 2022. 12. 3.
반응형

엔티티 대신에 DTO를 편리하게 조회할 때 사용

 

public interface UsernameOnly {
    String getUsername();
}

 

get + Entity.property 조건

 

 

List<UsernameOnly> findProjectionsByUsername(@Param("username") String username);

 

이후 JpaRepository를 가진 인터페이스에 위와 같은 시그니처를 등록하면 끝이다.

 

이렇게 하면 해당 구현체가 담겨서 온다.  확인해보자.

 

@Test
public void testMember () throws Exception {
    //given
    Member member = Member.builder()
            .username("username")
            .build();

    Member member2 = Member.builder()
            .username("username2")
            .build();

    em.persist(member);
    em.persist(member2);

    em.flush();
    em.clear();
    //when

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

    System.out.println(username);
}

 

 

해당 속성 값만 가져오는 쿼리를 확인할 수 있다.

 

특정 컬럼만 필요한 경우 엔티티를 전체 조회하는 것은 불필요한 공간을 낭비할 수 있다. 

보통 이를 대체하기 위해 DTO의 패키지명까지 받는 jpql을 직접 사용해서 처리하였는데 스프링 데이터 jpa는 이를 지원한다. 

 

이는 프록시 기술을 가지고 인터페이스의 구현체를 스프링 데이터 JPA가 만들어서 반환을 해준다. 

 

[org.springframework.data.jpa.repository.query.AbstractJpaQuery$TupleConverter$TupleBackedMap@2487b621]

 

실제 sout으로 찍힌 부분을 보면 다음과 같다.

 

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

 

이것은 OpenProejection이라고 한다. 

 

이는 username과 age를 둘 다 가져와서 문자를 담아서 값을 만들어 준다.

 

Hibernate: 
    select
        member0_.id as id1_0_,
        member0_.created_at as created_2_0_,
        member0_.created_by as created_3_0_,
        member0_.updated_at as updated_4_0_,
        member0_.updated_by as updated_5_0_,
        member0_.age as age6_0_,
        member0_.team_id as team_id8_0_,
        member0_.username as username7_0_ 
    from
        member member0_ 
    where
        member0_.username=?
2022-12-03 19:02:17.216 TRACE 1204 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [username]
2022-12-03 19:02:17.217 TRACE 1204 --- [           main] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_0_] : [BIGINT]) - [1]
2022-12-03 19:02:17.219 TRACE 1204 --- [           main] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([created_2_0_] : [TIMESTAMP]) - [null]
2022-12-03 19:02:17.219 TRACE 1204 --- [           main] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([created_3_0_] : [VARCHAR]) - [null]
2022-12-03 19:02:17.219 TRACE 1204 --- [           main] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([updated_4_0_] : [TIMESTAMP]) - [null]
2022-12-03 19:02:17.219 TRACE 1204 --- [           main] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([updated_5_0_] : [VARCHAR]) - [null]
2022-12-03 19:02:17.219 TRACE 1204 --- [           main] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([age6_0_] : [INTEGER]) - [null]
2022-12-03 19:02:17.219 TRACE 1204 --- [           main] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([team_id8_0_] : [BIGINT]) - [null]
2022-12-03 19:02:17.219 TRACE 1204 --- [           main] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([username7_0_] : [VARCHAR]) - [username]
username null
2022-12-03 19:02:17.251  INFO 1204 --- [           main] o.s.t.c.transaction.TransactionContext   : Rolled back transaction for test: [DefaultTestContext@72ea6193 testClass = MemberJpaRepositoryTest, testInstance = study.datajpa.repository.MemberJpaRepositoryTest@7f57a7a4, testMethod = testMember@MemberJpaRepositoryTest, testException = [null], mergedContextConfiguration = [WebMergedContextConfiguration@31aa3ca5 testClass = MemberJpaRepositoryTest, locations = '{}', classes = '{class study.datajpa.DataJpaApplication}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@1b410b60, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@0, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@30b6ffe0, org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@24c22fe, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@15a04efb, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@560348e6, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@37574691], resourceBasePath = 'src/main/webapp', contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map['org.springframework.test.context.web.ServletTestExecutionListener.activateListener' -> true, 'org.springframework.test.context.web.ServletTestExecutionListener.populatedRequestContextHolder' -> true, 'org.springframework.test.context.web.ServletTestExecutionListener.resetRequestContextHolder' -> true, 'org.springframework.test.context.event.ApplicationEventsTestExecutionListener.recordApplicationEvents' -> false]]

 

이 경우에는 특정 속성만을 쿼리 하지는 못한다. 전체를 쿼리 한 다음 애플리케이션 레벨에서 해당 문자열 계산을 통해 제공하게 된다.

 

또한 클래스 레벨의 Projections도 가능하다.


public class UsernameOnlyDto {

    private final String username;

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

    public String username() {
        return username;
    }
}
UsernameOnlyDto findProjectionsByUsername(@Param("username") String username);

 

이 경우는 프락시 기술이 필요 없다. 실제 구현체를 제공하기 때문이다. 

 

만약 해당 속성을 공유하는 여러 DTO가 존재한다면 제네릭을 사용하여 다양한 타입을 사용할 수 있다.

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

 

이러한 기술을 사용하는 기준은 대부분 join을 어디까지 지원하는가에 대해 달려있다.

이번에는 연관된 엔티티를 Projections으로 가져오는 것을 확인해보자.

 

 

public interface NestedClosedProjections {

    String getUsername();

    TeamInfo getTeam();

    interface TeamInfo {
        String getName();
    }
}

 

유저 이름과 팀의 이름을 가져오자.

NestedClosedProjections username = memberRepository.findProjectionsByUsername("username", NestedClosedProjections.class);

System.out.println(username.getUsername());
System.out.println(username.getTeam().getName());

 

로그를 보니 주 테이블에 관한 것은 최적화를 해서 가져오지만 연관된 엔티티는 전부 가져오는 것을 확인할 수 있다.

public interface NestedClosedProjections {

    String getUsername();

    Optional<TeamInfo> getTeam();

    interface TeamInfo {
        Optional<String> getName();
    }
}
NestedClosedProjections username = memberRepository.findProjectionsByUsername("username", NestedClosedProjections.class);

System.out.println(username.getUsername());
username.getTeam().flatMap(NestedClosedProjections.TeamInfo::getName).ifPresent(System.out::println);

 

아 물론 적절한 null처리를 해야 한다. 조건 중 관련 데이터가 없다면 바로 NPE가 발생한다.

 

 

조인은 left outer join을 지원하는 것 같다.

 

이는 프로젝션 대상이 root 엔티티면 JPQL SELECT절 최적화가 가능하다.

그렇지 않으면 LEFT OUTTER JOIN 처리를 하고, 모든 필드를 SELECT 하여 계산한다.

 

결국 엔티티가 하나를 넘어가는 순간 별다른 이점이 없다. 

솔직히 엔티티 여러 개인 경우 사용할만한 것 같고 그냥 DTO로 변환해서 사용하는 게 더 좋은 선택인 것 같다.

반응형

댓글