Post

Projection — 필요한 필드만 조회하는 방법

Projection — 필요한 필드만 조회하는 방법

1. 개요


엔티티 전체를 조회하면 필요 없는 컬럼까지 DB에서 읽어온다. 목록 조회에서 제목과 가격만 필요한데 SELECT *를 날리는 셈이다. Projection은 필요한 필드만 선택적으로 조회해서 데이터 전송량과 메모리 사용을 줄이는 기법이다.

Spring Data JPA는 인터페이스 기반, 클래스(DTO) 기반, 동적 Projection 세 가지 방식을 지원한다.

방식특징권장 상황
인터페이스 기반프록시 객체 생성, SpEL 지원빠른 선언, 간단한 가공
DTO(클래스) 기반실제 객체 생성, 명시적일반적인 DTO 변환
동적 Projection런타임에 타입 결정다양한 반환 타입이 필요한 경우
@QueryProjection컴파일 타임 타입 안전QueryDSL과 연동 시

2. 인터페이스 기반 Projection


📌 기본 사용

1
2
3
4
5
6
7
8
9
10
11
/**
 * 인터페이스에 getter 메서드만 선언하면 Spring이 프록시를 만들어 처리한다.
 * 메서드명이 엔티티 필드명과 일치해야 한다 (get + 필드명 대문자 시작)
 */
public interface BookSummary {
    String getTitle();
    int getPrice();
}

// 리포지토리에 선언
List<BookSummary> findByCategory(String category);

실행 쿼리:

1
2
-- 선언한 필드만 SELECT
SELECT b.title, b.price FROM book b WHERE b.category = 'IT';

📌 연관 엔티티 필드 접근

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface BookWithAuthor {
    String getTitle();
    int getPrice();

    /**
     * 중첩 Projection: Author 엔티티의 필드도 접근 가능
     * 단, 실제로는 SELECT *로 조회 후 매핑하므로 성능 이점이 없을 수 있다
     */
    AuthorInfo getAuthor();

    interface AuthorInfo {
        String getName();
        String getEmail();
    }
}
1
2
3
4
5
6
7
List<BookWithAuthor> findByCategory(String category);

// 사용
for (BookWithAuthor book : books) {
    System.out.println(book.getTitle());
    System.out.println(book.getAuthor().getName()); // 중첩 접근
}

📌 SpEL 표현식으로 가공

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface BookDisplay {
    String getTitle();
    int getPrice();

    /**
     * @Value 어노테이션으로 SpEL 표현식 사용 가능
     * target은 엔티티 객체를 가리킨다
     * 단, SpEL이 있으면 최적화 SELECT가 동작하지 않고 전체 엔티티를 로드한다
     */
    @Value("#{target.title + ' (' + target.price + '원)'}")
    String getTitleWithPrice();

    @Value("#{target.price > 20000 ? '고가' : '일반'}")
    String getPriceGrade();
}

💡 SpEL(@Value)이 포함되면 Spring이 필드를 추려서 SELECT할 수 없다. 전체 엔티티를 로드하고 SpEL을 적용하는 방식으로 동작한다. SpEL이 필요하면 DTO 기반 Projection에서 변환 로직을 넣는 게 낫다.


3. DTO 기반 Projection — 권장


📌 @Query + new 키워드

1
2
3
4
5
6
7
8
9
10
11
// DTO 클래스 정의 (record 사용 권장)
public record BookSummaryDto(String title, int price, String authorName) {}

// @Query에서 new 키워드로 직접 생성자 호출
@Query("""
    SELECT new com.example.dto.BookSummaryDto(b.title, b.price, a.name)
    FROM Book b
    JOIN b.author a
    WHERE b.category = :category
    """)
List<BookSummaryDto> findSummaryByCategory(@Param("category") String category);

실행 쿼리:

1
2
3
4
SELECT b.title, b.price, a.name
FROM book b
INNER JOIN author a ON b.author_id = a.id
WHERE b.category = 'IT';

인터페이스 기반과 달리 실제 객체를 직접 생성하므로 프록시 오버헤드가 없다.

📌 생성자 규칙

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
 * new 표현식에 들어가는 생성자 파라미터 순서와
 * DTO 생성자 파라미터 순서가 반드시 일치해야 한다.
 * record는 선언 순서대로 생성자가 자동 생성되므로 편리하다.
 */
public class BookSummaryDto {
    private final String title;
    private final int price;
    private final String authorName;

    // 이 생성자가 JPQL new 표현과 매핑됨
    public BookSummaryDto(String title, int price, String authorName) {
        this.title = title;
        this.price = price;
        this.authorName = authorName;
    }
}

📌 인터페이스 기반 vs DTO 기반 비교

항목인터페이스 기반DTO 기반
선언 방식인터페이스 getter클래스 생성자
생성 객체프록시실제 DTO 인스턴스
중첩 접근지원 (AuthorInfo 등)@Query로 직접 처리
SpEL 가공지원생성자 또는 팩터리 메서드에서 처리
컴파일 타임 검증부분적@QueryProjection으로 완전 지원
일반 권장도빠른 선언에 유리실무 일반 권장

4. 동적 Projection


📌 제네릭 반환 타입

1
2
3
4
5
6
7
8
9
10
/**
 * 반환 타입을 제네릭으로 선언하면 호출 시 타입을 결정할 수 있다.
 * 하나의 메서드로 여러 Projection 타입을 처리한다.
 */
<T> List<T> findByCategory(String category, Class<T> type);

// 사용
List<BookSummary> summaries = bookRepository.findByCategory("IT", BookSummary.class);
List<Book> books = bookRepository.findByCategory("IT", Book.class);
List<BookSummaryDto> dtos = bookRepository.findByCategory("IT", BookSummaryDto.class);

5. @QueryProjection — QueryDSL과 통합 시 타입 안전 Projection


QueryDSL을 사용할 경우 @QueryProjection으로 컴파일 타임 타입 안전성을 확보한다. Phase 9에서 상세히 다루지만, 개념만 미리 살펴본다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
 * DTO 생성자에 @QueryProjection을 붙이면
 * Q클래스 생성 시 QBookSummaryDto가 함께 만들어진다.
 */
public class BookSummaryDto {

    @QueryProjection
    public BookSummaryDto(String title, int price, String authorName) {
        this.title = title;
        this.price = price;
        this.authorName = authorName;
    }
}

// QueryDSL에서 사용 (Phase 9에서 상세 설명)
List<BookSummaryDto> result = queryFactory
    .select(new QBookSummaryDto(book.title, book.price, author.name))
    .from(book)
    .join(book.author, author)
    .where(book.category.eq("IT"))
    .fetch();

6. 실전 패턴 — 엔티티 → DTO 변환


📌 서비스 계층에서 변환

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 서비스에서 엔티티를 DTO로 변환하는 일반 패턴
@Service
@RequiredArgsConstructor
public class BookService {

    private final BookRepository bookRepository;

    public List<BookSummaryDto> getBookSummaries(String category) {
        // 방법 1: @Query + new 표현 (DB 레벨 변환)
        return bookRepository.findSummaryByCategory(category);

        // 방법 2: 전체 엔티티 조회 후 스트림 변환 (작은 데이터)
        // return bookRepository.findByCategory(category)
        //     .stream()
        //     .map(b -> new BookSummaryDto(b.getTitle(), b.getPrice(), b.getAuthor().getName()))
        //     .toList();
    }
}

방법 1은 SELECT할 컬럼을 줄여 성능이 낫고, 방법 2는 유연성이 높지만 불필요한 컬럼까지 읽는다. 대량 데이터에서는 방법 1을 선택한다.


7. 정리


  • Projection은 필요한 컬럼만 선택 조회해서 성능을 개선하는 기법이다
  • 인터페이스 기반은 프록시를 만들어 빠르게 선언하고, SpEL로 가공이 가능하다. 단, SpEL이 있으면 전체 로드된다
  • DTO 기반(new 생성자)은 실제 객체를 직접 생성하며, 실무 일반 권장 방식이다
  • 동적 Projection은 Class<T> 파라미터로 런타임에 반환 타입을 결정한다
  • QueryDSL과 함께 쓸 때는 @QueryProjection으로 컴파일 타임 타입 안전성을 확보한다

참고

This post is licensed under CC BY 4.0 by the author.