여러 테이블을 조인한 네이티브 쿼리 결과를 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로 꼭 받아야 한다면 두 가지 방법이 있다.
- 쿼리를 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);
- 네이티브 쿼리를 유지해야 하면
@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) {}