본문으로 건너뛰기
김신건의 로그

[JPA] JPQL DTO Projection: constructor expression, interface, record

· 수정 · 📖 약 3분 · 970자/단어 #jpa #hibernate #jpql #projection #dto #performance
JPQL projection, JPQL DTO, constructor expression, interface projection, Tuple, JPQL NEW 키워드, JPA projection

정의

DTO Projection 은 entity 전체가 아닌 필요한 컬럼만 query 해서 DTO 로 받기. 장점:

  • N+1 회피 (lazy 컬렉션 자동 무시)
  • entity 전체 hydration 비용 절감
  • API 응답에 맞춘 형태 (entity 그대로 반환은 anti-pattern)
  • read-only 의도 명확

JPQL 의 4가지 projection 방식 + Spring Data JPA 의 추가 옵션 2가지.

1. Constructor Expression (NEW)

JPQL 표준. DTO 클래스의 생성자 시그니처와 매칭:

public record UserSummary(Long id, String name, String email) {}

List<UserSummary> result = em.createQuery(
    "SELECT NEW com.example.UserSummary(u.id, u.name, u.email) FROM User u",
    UserSummary.class
).getResultList();

핵심:

  • FQCN (full class name) 필수: NEW com.example.UserSummary(...)
  • 생성자 시그니처 정확히 일치: 순서 / 타입
  • record 권장 (Java 14+, immutable)
  • class 도 OK: ctor + getter 만 있으면
public class UserSummary {
    private final Long id;
    private final String name;
    private final String email;

    public UserSummary(Long id, String name, String email) { ... }
    // getter
}

장점:

  • 표준 JPQL → 모든 provider 호환
  • 타입 안전
  • 가장 깔끔

단점:

  • FQCN 길어서 가독성 낮음
  • DTO 변경 시 query 수정 필요

NamedConstructorResult (native query)

@SqlResultSetMapping(
    name = "UserSummaryMapping",
    classes = @ConstructorResult(
        targetClass = UserSummary.class,
        columns = {
            @ColumnResult(name = "id", type = Long.class),
            @ColumnResult(name = "name"),
            @ColumnResult(name = "email")
        }
    )
)
@NamedNativeQuery(
    name = "User.summary",
    query = "SELECT id, name, email FROM users",
    resultSetMapping = "UserSummaryMapping"
)

native query 에서 DTO 매핑. 보일러플레이트 많음.

2. Interface-Based Projection (Spring Data JPA)

public interface UserSummary {
    Long getId();
    String getName();
    String getEmail();
}

public interface UserRepository extends JpaRepository<User, Long> {
    List<UserSummary> findAllProjectedBy();
    UserSummary findByEmail(String email);
}

Spring Data 가 자동으로 proxy 만들어 매핑. getter 이름이 entity field 와 일치 필요 (snake_case 가 아니라 camelCase).

Closed projection (필드만)

public interface UserSummary {
    Long getId();
    String getName();
}

Spring Data 가 SELECT 절을 최적화 → 필요한 컬럼만 쿼리.

Open projection (SpEL)

public interface UserSummary {
    Long getId();

    @Value("#{target.firstName + ' ' + target.lastName}")
    String getFullName();
}

SpEL 사용. 단점: 전체 entity 가 로드된 후 메서드 호출 (최적화 안 됨).

3. Class-Based Projection (DTO via Spring Data)

public record UserSummary(Long id, String name, String email) {}

public interface UserRepository extends JpaRepository<User, Long> {
    List<UserSummary> findAllProjectedBy();
}

Spring Data 가 매개변수 이름 → entity field 매칭 (record 의 component 이름 활용).

Spring Data JPA 3.x+ 가 가장 자연스러움.

4. Tuple

List<Tuple> results = em.createQuery(
    "SELECT u.id AS id, u.name AS name, u.email AS email FROM User u",
    Tuple.class
).getResultList();

for (Tuple t : results) {
    Long id = t.get("id", Long.class);
    String name = t.get("name", String.class);
}

DTO 클래스 불필요. 동적 쿼리 결과에 적합. 단점: 타입 안전성 낮음 (string key + cast).

5. Dynamic Projection (Spring Data JPA)

같은 메서드를 여러 projection 으로:

public interface UserRepository extends JpaRepository<User, Long> {
    <T> List<T> findByActiveTrue(Class<T> type);
}

// 사용
List<UserSummary> summary = repo.findByActiveTrue(UserSummary.class);
List<UserDetail> detail = repo.findByActiveTrue(UserDetail.class);
List<User> entities = repo.findByActiveTrue(User.class);   // entity 전체도

가장 유연. API 응답 형태에 따라 projection 동적 선택.

5가지 방식 비교

방식표준Spring Data 의존타입 안전보일러추천도
Constructor expression (NEW)JPQL 표준X중간 (FQCN)★★★
Interface projectionSpring Data낮음★★★★
Class projection (record)Spring Data낮음★★★★★
TupleJPA 표준X낮음★★
Dynamic (Class<T>)Spring Data가장 낮음★★★★★

Spring Data JPA 환경: dynamic projection + record 가 모던 best.

자주 보는 패턴

패턴 1: 화면별 DTO

// 리스트 화면
public record UserListItem(Long id, String name, String email) {}

// 상세 화면
public record UserDetail(Long id, String name, String email, String bio, Instant createdAt, int postCount) {}

public interface UserRepository extends JpaRepository<User, Long> {
    Page<UserListItem> findByActive(boolean active, Pageable pageable);

    @Query("""
        SELECT new com.example.UserDetail(
            u.id, u.name, u.email, u.bio, u.createdAt, SIZE(u.posts))
        FROM User u WHERE u.id = :id
    """)
    UserDetail findDetailById(@Param("id") Long id);
}

각 API 요구에 맞춘 projection. entity 그대로 반환은 over-fetch.

패턴 2: 통계 / 집계

public record UserPostStat(Long userId, String name, Long postCount, Long commentCount) {}

@Query("""
    SELECT new com.example.UserPostStat(
        u.id, u.name, COUNT(DISTINCT p), COUNT(DISTINCT c))
    FROM User u
    LEFT JOIN u.posts p
    LEFT JOIN u.comments c
    GROUP BY u.id, u.name
""")
List<UserPostStat> findUserStats();

집계 결과를 record 로 직접 받음.

패턴 3: nested DTO

public record OrderSummary(Long id, OrderUser user, BigDecimal total) {
    public record OrderUser(Long id, String name) {}
}

@Query("""
    SELECT new com.example.OrderSummary(
        o.id,
        new com.example.OrderSummary$OrderUser(u.id, u.name),
        o.total)
    FROM Order o JOIN o.user u
""")
List<OrderSummary> findAllSummary();

JPQL 의 NEW 안에 NEW 가능 (Hibernate). 가독성 낮으니 interface projection 이 더 깔끔할 수도.

함정

함정 1: Open projection 의 SELECT 최적화 안 됨

public interface UserSummary {
    Long getId();
    @Value("#{target.firstName + ' ' + target.lastName}")
    String getFullName();
}

SELECT u.id, u.first_name, u.last_name, u.email, ... (전체) → 메모리에서 SpEL 평가. closed projection 이 성능 우위.

함정 2: nested entity 의 lazy 함정

public interface PostSummary {
    Long getId();
    String getTitle();
    UserSummary getAuthor();   // nested projection

    interface UserSummary {
        Long getId();
        String getName();
    }
}

Spring Data 가 JOIN 자동 추가 (closed projection). 다만 getAuthor() 가 entity 면 lazy 함정 발생 가능.

함정 3: constructor expression 의 entity 인자

SELECT new com.example.PostSummary(p.id, p.title, p.author) FROM Post p

p.author 가 entity → 전체 hydration. 의도 안 맞으면:

SELECT new com.example.PostSummary(p.id, p.title, p.author.id, p.author.name) FROM Post p

scalar 만 사용.

함정 4: COUNT(DISTINCT) + JOIN FETCH

SELECT new ... (... COUNT(DISTINCT p) ...) FROM User u LEFT JOIN u.posts p
GROUP BY u.id

u.posts 가 LAZY 면 그냥 JOIN 으로 처리됨 (FETCH 아님). 의도와 다를 수 있음. EXPLAIN 으로 확인.

함정 5: Pageable + projection

Page<UserSummary> findByActive(boolean active, Pageable pageable);

count query 가 자동 생성. complex projection 이면 count 가 비효율적. @QuerycountQuery 옵션:

@Query(value = "SELECT new ... FROM User u JOIN u.posts p ...",
       countQuery = "SELECT COUNT(DISTINCT u) FROM User u")
Page<UserStat> findStat(Pageable pageable);

Performance: projection 의 효과

시나리오entity 로드projection 로드차이
단일 필드만 사용100% 컬럼 SELECT + lazy 필드필요 컬럼만 SELECTDB I/O 절감
collection 사용 안 함LAZY proxy 생성무관proxy 비용 절감
dirty checking 미사용snapshot 저장안 함메모리 절감
트랜잭션 종료 후 사용LazyInitializationException 위험안전 (값만)안정성

read-heavy API 에서 큰 차이. 권장: 모든 read API 가 projection 으로 응답, entity 그대로 반환 금지.

함정과 베스트 프랙티스

  • entity 그대로 API 반환 금지: projection 으로 분리
  • record 우선: immutable + 간결
  • closed projection (필드만) 이 SELECT 최적화 가능
  • dynamic projection (Class<T>) 이 가장 유연
  • constructor expression 의 FQCN 보일러: static import 또는 named query 활용
  • nested entity 인자 피하기: scalar 만 사용
  • Pageable + projection 의 count query 명시: 자동 count 비효율 시
  • DTO 가 API 계약: schema 변경 시 versioning 신중
  • @Transactional(readOnly = true) 가 projection 메서드의 기본

관련 위키

이 글의 용어 (5개)
[JPA] 동적 쿼리: Criteria API, QueryDSL, Specificationspring
정의 동적 쿼리 = 런타임 조건에 따라 WHERE / JOIN 이 달라지는 쿼리. 검색 필터, admin 도구, 리포팅 등에서 흔함. JPQL string concatenatio…
[JPA] FetchType: LAZY vs EAGER, N+1, fetch joinspring
정의 FetchType 은 JPA 가 연관 entity 를 언제 로드할지 결정하는 hint. - : entity 로딩 시 즉시 함께 SELECT - : 실제 사용 시점 (prox…
[JPA] JPQL: Jakarta Persistence Query Languagespring
정의 JPQL 은 Jakarta Persistence 표준의 객체 지향 쿼리 언어. 문법은 SQL 과 유사하지만 entity 와 그 필드를 대상으로 작동 (table / colu…
[JPA] PersistenceContext: 1차 캐시, dirty checking, flushspring
정의 PersistenceContext 는 가 관리하는 entity 의 메모리 저장소 + 변경 추적기. 트랜잭션 안에서만 살아 있다 ( 진입 시 생성, commit/rollbac…
[Spring] Spring Data JPA: Repository, Query Methodsspring
정의 Spring Data JPA는 JPA(Hibernate) 위에 Repository 추상화를 얹어 boilerplate를 제거. 인터페이스만 정의하면 Spring이 구현체 자…

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기