Backend
Last updated on

[Spring Boot] JPA 네이티브 쿼리 결과를 DTO로 받기 (No converter found 에러)


여러 테이블을 조인한 네이티브 쿼리 결과를 DTO로 받으려다 발생한 에러와 해결 방법을 정리한다.

작성 당시 환경은 다음과 같다. 최신 버전에서 달라진 부분은 글 마지막에 따로 정리했다.

항목 버전
Spring Boot 2.7.x
Spring Data JPA 2.7.x
Hibernate 5.6.x

상황

주문 목록 화면에 주문 정보, 주문자 이름, 상품 이름을 함께 보여줘야 했다. 세 테이블을 조인하는 네이티브 쿼리를 작성하고, 결과를 DTO 클래스로 받도록 했다.

@Getter
@AllArgsConstructor
public class OrderSummaryDto {
    private Long orderId;
    private String memberName;
    private String productName;
    private Integer quantity;
}
@Query(value = "SELECT o.id AS orderId, m.name AS memberName,"
             + "       p.name AS productName, o.quantity AS quantity"
             + "  FROM orders o"
             + "  JOIN member m ON o.member_id = m.id"
             + "  JOIN product p ON o.product_id = p.id"
             + " WHERE m.id = :memberId",
       nativeQuery = true)
List<OrderSummaryDto> findOrderSummaries(@Param("memberId") Long memberId);

실행하면 다음 에러가 발생한다.

No converter found capable of converting from type
[org.springframework.data.jpa.repository.query.AbstractJpaQuery$TupleConverter$TupleBackedMap]
to type [com.example.OrderSummaryDto]

원인

JPQL과 네이티브 쿼리의 결과 차이

JPQL은 엔티티를 대상으로 하는 쿼리라서, JPA가 결과를 엔티티 객체로 만들어 준다. 반면 네이티브 쿼리는 DB에 SQL을 그대로 보내므로, JPA는 결과의 각 컬럼이 어떤 객체의 어느 필드에 들어가야 하는지 알지 못한다. 그래서 결과를 단순히 **값의 배열(Object[]) 또는 컬럼 이름과 값의 묶음(Tuple)**으로 돌려준다.

JPQL         SELECT o FROM Order o       →  Order 객체
네이티브 쿼리  SELECT o.id, m.name ...     →  [1, "kim", ...]  (Object[] / Tuple)

Spring Data JPA의 변환 과정

Spring Data JPA는 메서드의 반환 타입에 맞춰 이 결과를 변환하려고 한다. 네이티브 쿼리 결과는 내부적으로 TupleBackedMap(컬럼 alias → 값의 Map)으로 감싸지는데, 이를 임의의 클래스로 바꾸는 변환기는 없다. 생성자 인자 순서나 필드 이름을 추측해서 채워 주지 않는다.

에러 메시지는 “TupleBackedMap을 OrderSummaryDto로 바꿀 변환기를 찾지 못했다”는 뜻이다.

Object[]로 받으면?

반환 타입을 List<Object[]>로 하면 에러 없이 받을 수 있다.

List<Object[]> findOrderSummaries(@Param("memberId") Long memberId);

for (Object[] row : rows) {
    Long orderId = ((Number) row[0]).longValue();
    String memberName = (String) row[1];
    ...
}

하지만 값을 인덱스로 꺼내고 직접 형변환해야 한다. SELECT 절 컬럼 순서가 바뀌면 컴파일 에러 없이 잘못된 값이 들어가고, 숫자 타입은 DB 드라이버에 따라 Integer, BigInteger, BigDecimal 등으로 달라질 수 있어 다루기 번거롭다.


해결: 인터페이스 기반 프로젝션

Spring Data JPA는 인터페이스를 반환 타입으로 지정하면, 쿼리 결과를 감싸는 구현체(프록시)를 자동으로 만들어 준다. 이를 인터페이스 기반 프로젝션이라 한다.

public interface OrderSummary {
    Long getOrderId();
    String getMemberName();
    String getProductName();
    Integer getQuantity();
}
@Query(value = "SELECT o.id AS orderId, m.name AS memberName,"
             + "       p.name AS productName, o.quantity AS quantity"
             + "  FROM orders o"
             + "  JOIN member m ON o.member_id = m.id"
             + "  JOIN product p ON o.product_id = p.id"
             + " WHERE m.id = :memberId",
       nativeQuery = true)
List<OrderSummary> findOrderSummaries(@Param("memberId") Long memberId);
List<OrderSummary> summaries = orderRepository.findOrderSummaries(1L);

for (OrderSummary s : summaries) {
    System.out.println(s.getOrderId() + " / " + s.getMemberName() + " / " + s.getProductName());
}

동작 원리: alias와 getter 이름을 맞춘다

프록시는 getMemberName()이 호출되면 getter 이름에서 get을 뗀 memberName을 키로 TupleBackedMap에서 값을 찾는다. 따라서 SQL의 alias가 getter의 프로퍼티 이름과 같아야 한다.

SQL getter 결과
m.name AS memberName getMemberName() 정상
m.name (alias 없음) getMemberName() 컬럼명이 name이라 못 찾음 → null
m.name AS member_name getMemberName() 이름이 달라 못 찾음 → null

alias가 맞지 않으면 에러가 나지 않고 조용히 null이 반환되므로, 값이 비어 있다면 가장 먼저 alias를 확인한다. DB 컬럼이 snake_case라면 alias로 camelCase 이름을 붙여 주는 것을 잊지 않도록 한다.

숫자 타입은 프록시가 getter의 반환 타입으로 변환해 준다. 예를 들어 DB 드라이버가 BigInteger로 돌려준 값도 Long getOrderId()로 받을 수 있다.


페이징이 필요할 때: countQuery

처음에 작성한 쿼리는 페이징 결과(Page)를 반환했다. 네이티브 쿼리에서 Page를 쓰려면 전체 개수를 구하는 쿼리(countQuery)를 함께 지정하는 것이 안전하다.

@Query(value = "SELECT o.id AS orderId, m.name AS memberName,"
             + "       p.name AS productName, o.quantity AS quantity"
             + "  FROM orders o"
             + "  JOIN member m ON o.member_id = m.id"
             + "  JOIN product p ON o.product_id = p.id"
             + " WHERE m.id = :memberId",
       countQuery = "SELECT count(*)"
             + "  FROM orders o"
             + "  JOIN member m ON o.member_id = m.id"
             + " WHERE m.id = :memberId",
       nativeQuery = true)
Page<OrderSummary> findOrderSummaries(@Param("memberId") Long memberId, Pageable pageable);

Page는 현재 페이지 데이터와 함께 전체 개수(totalElements)가 필요하다. JPQL은 Spring Data JPA가 count 쿼리를 자동으로 만들어 주지만, 네이티브 SQL은 구조를 정확히 분석하기 어려워 자동 생성이 실패하거나 잘못된 쿼리가 만들어질 수 있다.

count 쿼리에는 개수에 영향을 주지 않는 조인(product)을 빼서 더 가볍게 만들 수 있다.


다른 방법과 비교

방법 네이티브 쿼리 특징
Object[] 가능 인덱스로 접근, 직접 형변환 필요
인터페이스 프로젝션 가능 alias만 맞추면 됨. 가장 간단
클래스 DTO + new 생성자 표현식 불가 (JPQL 전용) SELECT new com.example.OrderSummaryDto(o.id, ...)
@SqlResultSetMapping + @ConstructorResult 가능 클래스 DTO로 받을 수 있지만 엔티티에 매핑 설정이 길게 붙음

클래스 DTO로 꼭 받아야 한다면 두 가지 방법이 있다.

  1. 쿼리를 JPQL로 바꿀 수 있으면 생성자 표현식을 쓴다.
@Query("SELECT new com.example.OrderSummaryDto(o.id, m.name, p.name, o.quantity)"
     + "  FROM Order o JOIN o.member m JOIN o.product p"
     + " WHERE m.id = :memberId")
List<OrderSummaryDto> findOrderSummaries(@Param("memberId") Long memberId);
  1. 네이티브 쿼리를 유지해야 하면 @SqlResultSetMapping으로 컬럼과 생성자 인자의 매핑을 직접 선언한다.
@Entity
@SqlResultSetMapping(
    name = "OrderSummaryMapping",
    classes = @ConstructorResult(
        targetClass = OrderSummaryDto.class,
        columns = {
            @ColumnResult(name = "orderId", type = Long.class),
            @ColumnResult(name = "memberName", type = String.class),
            @ColumnResult(name = "productName", type = String.class),
            @ColumnResult(name = "quantity", type = Integer.class)
        }))
@NamedNativeQuery(
    name = "Order.findOrderSummaries",
    query = "SELECT o.id AS orderId, m.name AS memberName, p.name AS productName, o.quantity AS quantity"
          + "  FROM orders o JOIN member m ON o.member_id = m.id JOIN product p ON o.product_id = p.id"
          + " WHERE m.id = :memberId",
    resultSetMapping = "OrderSummaryMapping")
@Table(name = "orders")
public class Order { ... }
@SuppressWarnings("unchecked")
List<OrderSummaryDto> result = em.createNamedQuery("Order.findOrderSummaries")
        .setParameter("memberId", memberId)
        .getResultList();

@ConstructorResult의 columns 순서대로 DTO 생성자가 호출된다. 생성자 인자의 순서와 타입이 정확히 맞아야 한다.

조회 전용 데이터라면 인터페이스 프로젝션으로 충분한 경우가 대부분이다.


Spring Boot 3 / Hibernate 6에서 달라진 점

  • 인터페이스 프로젝션은 그대로 동작한다. alias와 getter 이름을 맞추는 규칙도 같다.
  • 패키지 이름 — @SqlResultSetMapping, @ConstructorResult 등의 import가 javax.persistence에서 jakarta.persistence로 바뀐다.
  • record 사용 — Spring Boot 3는 Java 17 이상이 필요하므로, JPQL 생성자 표현식의 DTO를 record로 간단히 만들 수 있다.
public record OrderSummaryDto(Long orderId, String memberName,
                              String productName, Integer quantity) {}

참고