Spring JPA Projection 최적화

JPA Projection이란?

Spring Data JPA에서 엔티티 전체를 조회하면 불필요한 컬럼까지 SELECT된다. Projection은 필요한 컬럼만 선택적으로 조회하는 기법으로, 네트워크 대역폭과 메모리 사용량을 줄이고 쿼리 성능을 높인다. Spring Data JPA는 인터페이스 기반, 클래스 기반, 동적 Projection 세 가지 방식을 지원한다.

Interface Projection (Closed)

가장 간단한 방식이다. 인터페이스에 getter 메서드를 정의하면 Spring이 프록시 객체를 생성해준다.

// 엔티티
@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private String email;
    private String phone;
    private LocalDateTime createdAt;
}

// Closed Projection — 필요한 필드만 정의
public interface MemberSummary {
    String getName();
    String getEmail();
}

// Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    List<MemberSummary> findByName(String name);
}

실행되는 SQL은 SELECT m.name, m.email FROM member m WHERE m.name = ?이다. Closed Projection이라 부르는 이유는 인터페이스에 정의된 필드만 정확히 조회하기 때문이다. 엔티티의 phone, createdAt은 SELECT에 포함되지 않는다.

Interface Projection (Open)

@Value SpEL 표현식을 사용하면 컬럼을 조합하거나 변환할 수 있다. 이를 Open Projection이라 한다.

public interface MemberInfo {
    String getName();
    String getEmail();

    @Value("#{target.name + ' (' + target.email + ')'}")
    String getDisplayName();

    @Value("#{@memberFormatter.format(target)}")
    String getFormatted();
}

주의: Open Projection은 SpEL 평가를 위해 엔티티 전체를 조회한다. 성능 최적화 목적이라면 Closed Projection을 사용해야 한다. Open Projection은 표현의 유연성이 필요할 때만 쓰자.

Class 기반 Projection (DTO)

인터페이스 대신 클래스를 사용하면 프록시 없이 직접 인스턴스가 생성된다. 생성자 매개변수 이름이 엔티티 필드와 일치해야 한다.

// DTO 클래스
public record MemberDto(String name, String email) {}

// 또는 일반 클래스
@Getter
@AllArgsConstructor
public class MemberDto {
    private final String name;
    private final String email;
}

// Repository — 반환 타입만 바꾸면 됨
public interface MemberRepository extends JpaRepository<Member, Long> {
    List<MemberDto> findByName(String name);
}

Java 16+의 record를 사용하면 가장 깔끔하다. SQL은 Closed Interface Projection과 동일하게 필요한 컬럼만 SELECT한다. MapStruct DTO 매핑과 달리 별도 매퍼 없이 바로 사용할 수 있는 것이 장점이다.

Dynamic Projection

같은 쿼리에서 상황에 따라 다른 Projection을 적용하려면 제네릭 타입 파라미터를 사용한다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    // 동적 Projection — 호출 시 타입 결정
    <T> List<T> findByName(String name, Class<T> type);
    <T> Optional<T> findById(Long id, Class<T> type);
}

// 사용
List<MemberSummary> summaries = repo.findByName("홍길동", MemberSummary.class);
List<MemberDto> dtos = repo.findByName("홍길동", MemberDto.class);
List<Member> entities = repo.findByName("홍길동", Member.class);

API 응답 레벨에 따라 다른 Projection을 반환하는 패턴에 유용하다. 관리자는 전체 엔티티를, 일반 사용자는 요약만 받는 식이다.

중첩 Projection (연관관계)

연관 엔티티의 필드도 Projection으로 가져올 수 있다.

@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;
    private BigDecimal amount;
    private OrderStatus status;

    @ManyToOne(fetch = FetchType.LAZY)
    private Member member;
}

// 중첩 Projection
public interface OrderSummary {
    Long getId();
    BigDecimal getAmount();
    OrderStatus getStatus();
    MemberInfo getMember();  // 중첩 인터페이스

    interface MemberInfo {
        String getName();
        String getEmail();
    }
}

public interface OrderRepository extends JpaRepository<Order, Long> {
    List<OrderSummary> findByStatus(OrderStatus status);
}

주의: 중첩 Projection은 Open Projection처럼 동작한다. 연관 엔티티 전체를 로딩한 뒤 필요한 필드만 추출하므로, N+1 문제가 발생할 수 있다. 성능이 중요하다면 JPQL이나 @Query와 함께 사용하자.

@Query + Projection 조합

복잡한 쿼리에서도 Projection을 활용할 수 있다.

public interface MemberRepository extends JpaRepository<Member, Long> {

    // JPQL + Interface Projection
    @Query("SELECT m.name AS name, m.email AS email FROM Member m WHERE m.createdAt > :since")
    List<MemberSummary> findRecentMembers(@Param("since") LocalDateTime since);

    // JPQL + DTO Projection (new 연산자)
    @Query("SELECT new com.example.dto.MemberDto(m.name, m.email) FROM Member m WHERE m.name LIKE %:keyword%")
    List<MemberDto> searchByKeyword(@Param("keyword") String keyword);

    // Native Query + Interface Projection
    @Query(value = "SELECT name, email, COUNT(*) AS orderCount " +
                   "FROM member m JOIN orders o ON m.id = o.member_id " +
                   "GROUP BY m.id", nativeQuery = true)
    List<MemberWithOrderCount> findMembersWithOrderCount();
}

public interface MemberWithOrderCount {
    String getName();
    String getEmail();
    Long getOrderCount();  // AS 별칭과 일치해야 함
}

Native Query에서 Interface Projection을 사용할 때는 SELECT 별칭(AS)이 getter 이름과 정확히 일치해야 한다. order_count처럼 snake_case라면 getOrder_count()가 아니라 getOrderCount()로 매핑된다(Spring이 자동 변환).

Projection 성능 비교

방식 SQL 최적화 프록시 추천 용도
Closed Interface ✅ 필요 컬럼만 생성됨 읽기 전용 목록 API
Open Interface ❌ 전체 조회 생성됨 컬럼 조합 필요 시
Class (DTO/Record) ✅ 필요 컬럼만 없음 서비스 간 데이터 전달
Dynamic 타입에 따름 타입에 따름 권한별 응답 분기
중첩 Interface ❌ 연관 전체 로딩 생성됨 간단한 조인만

실전 패턴: 목록·상세 분리

실무에서 가장 흔한 패턴이다. 목록 API는 가벼운 Projection, 상세 API는 엔티티 전체를 반환한다.

// 목록용 (가벼움)
public record MemberListItem(Long id, String name, String email) {}

// 상세용 (풍부함)
public record MemberDetail(
    Long id, String name, String email, String phone,
    String address, LocalDateTime createdAt
) {}

// Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    Page<MemberListItem> findAllBy(Pageable pageable);
    Optional<MemberDetail> findDetailById(Long id);
}

// Controller
@RestController
@RequestMapping("/members")
public class MemberController {
    @GetMapping
    public Page<MemberListItem> list(Pageable pageable) {
        return memberRepo.findAllBy(pageable);
    }

    @GetMapping("/{id}")
    public MemberDetail detail(@PathVariable Long id) {
        return memberRepo.findDetailById(id)
            .orElseThrow(() -> new ResponseStatusException(NOT_FOUND));
    }
}

목록 API에서 20개 컬럼 엔티티를 매번 전체 SELECT하는 것과 3개 컬럼만 가져오는 것은 대량 데이터에서 체감 차이가 크다.

QueryDSL + Projection

동적 쿼리가 필요하면 QueryDSL의 Projections 유틸을 사용한다.

public List<MemberDto> searchMembers(MemberSearchCond cond) {
    return queryFactory
        .select(Projections.constructor(MemberDto.class,
            member.name,
            member.email
        ))
        .from(member)
        .where(
            nameContains(cond.getName()),
            emailContains(cond.getEmail())
        )
        .fetch();
}

// 또는 @QueryProjection 활용
public record MemberDto(
    @QueryProjection String name,
    @QueryProjection String email
) {}

// Q클래스 생성 후
.select(new QMemberDto(member.name, member.email))

Projections.constructor()는 런타임에 매핑하고, @QueryProjection은 컴파일 타임에 타입을 검증한다. 안정성이 중요한 프로젝트라면 @QueryProjection을 권장한다.

정리

Projection은 “필요한 것만 가져오기”라는 단순한 원칙이지만, 방식 선택에 따라 성능이 크게 달라진다. 읽기 전용 API는 Closed Interface나 Record DTO를, 동적 쿼리는 QueryDSL Projection을, 중첩 연관은 JPQL JOIN FETCH와 조합하는 것이 실무 최적해다. 엔티티를 직접 반환하는 습관부터 고치자.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux