JPA 반환타입을 DTO로 변환할 때 발생하는 ConverterNotFoundException예외

Alex·2024년 9월 6일

Binder프로젝트

목록 보기
5/18

JPA를 사용해서 쿼리를 직접 짤 때는 JPQL과 네이티브 쿼리를 쓸 수 있다.
네이티브 쿼리는 일반적으로 사용하는 SQL 쿼리를 쓸수 있도록 해주는 기능이다. JPQL은 테이블이 아닌 객체를 대상으로 쿼리를 작성한다. DB에 독립적인 쿼이다.

이러한 쿼리들을 사용하면서 계속 생긴 문제가 있었따. 바로 적절한 컨버터가 없다는 예외였다.

네이티브 쿼리를 처음 써봤는데 계속 이런 문제가 발생했다.

주로 생긴 문제는 생성자의 필드와 쿼리에 입력되는 칼럼들이 정확히 일치하지 않아서 생긴 것들이다. 조회하는 컬럼들과 생성자의 매개변수들이, 타입과 수가 일치해야 한다.

쿼리 사용에 생기는 문제

    @Query("""
    SELECT new net.binder.api.bin.dto.BinDetailResponseForLoginUser(
        b.id, b.createdAt, b.modifiedAt, b.title, b.type,
        ST_X(b.point), ST_Y(b.point), b.address,
        b.likeCount, b.dislikeCount, b.imageUrl,
        CASE WHEN mlb IS NOT NULL THEN true ELSE false END,
        CASE WHEN mdb IS NOT NULL THEN true ELSE false END,
        CASE WHEN mb IS NOT NULL THEN true ELSE false END
    )
    FROM Bin b
    LEFT JOIN MemberLikeBin mlb ON b.id = mlb.bin.id AND mlb.member.id = :memberId
    LEFT JOIN MemberDislikeBin mdb ON b.id = mdb.bin.id AND mdb.member.id = :memberId
    LEFT JOIN BookMark mb ON b.id = mb.bin.id AND mb.member.id = :memberId
    WHERE b.deletedAt IS NULL AND b.id = :binId
    """)
    Optional<BinDetailResponseForLoginUser> findDetailByIdAndMemberId(@Param("binId") Long binId, @Param("memberId") Long memberId);
}

이렇게 ST_X(b.point) 를 사용하면 계속 예외가 발생했다. JPQL에서는 ST_X와 같은 데이터베이스 특정 함수를 지원하지 않은 거 같았다.

이때 DTO를 바로 맵핑해서 쓰려고 하니까 안 됐다.


    @Query("""
                SELECT b.id as id, b.createdAt as createdAt, b.modifiedAt as modifiedAt, 
                       b.title as title, b.type as type,
                       function('ST_X', b.point) as latitude, function('ST_Y', b.point) as longitude, b.address as address,
                       b.likeCount as likeCount, b.dislikeCount as dislikeCount, 
                       b.bookmarkCount as bookmarkCount, b.imageUrl as imageUrl,
                       CASE WHEN br.id IS NOT NULL AND br.member.id = :memberId THEN true ELSE false END as isOwner,
                       CASE WHEN mlb.id IS NOT NULL THEN true ELSE false END as isLiked,
                       CASE WHEN mdb.id IS NOT NULL THEN true ELSE false END as isDisliked,
                       CASE WHEN bm.id IS NOT NULL THEN true ELSE false END as isBookmarked
                FROM Bin b
                LEFT JOIN BinRegistration br ON b.id = br.bin.id AND br.member.id = :memberId
                LEFT JOIN MemberLikeBin mlb ON b.id = mlb.bin.id AND mlb.member.id = :memberId
                LEFT JOIN MemberDislikeBin mdb ON b.id = mdb.bin.id AND mdb.member.id = :memberId
                LEFT JOIN Bookmark bm ON b.id = bm.bin.id AND bm.member.id = :memberId
                WHERE b.deletedAt IS NULL AND b.id = :binId
            """)
            

JPAL에서는 데이터 베이스의 함수들을 사용할 수 있도록 해주는 function기능이 존재하고 이를 활용해야 했다.

Missing Constructor

Caused by: java.lang.IllegalArgumentException: org.hibernate.query.SemanticException: Missing constructor for type 'BinDetailResponseForLoginUser

쿼리를 변경해도 계속 적절한 생성자가 없다는 문제가 발생했다.
조회하는 컬럼들에 맞춰서 DTO 생성자를 만들어도 그랬다.

Solving Spring Data JPA ConverterNotFoundException: No converter found

이 글을 봐보자.

@Entity
public class Employee {

    @Id
    private int id;
    @Column
    private String firstName;
    @Column
    private String lastName;
    @Column
    private double salary;

    // standards getters and setters

}

@Repository
public interface EmployeeRepository extends JpaRepository<Employee, Integer> {
}

public class EmployeeFullName {

    private String firstName;
    private String lastName;

    // standards getters and setters

    public String fullName() {
        return getFirstName()
          .concat(" ")
          .concat(getLastName());
    }

}

@Test
void givenEmployee_whenGettingFullName_thenThrowException() {
    Employee emp = new Employee();
    emp.setId(1);
    emp.setFirstName("Adrien");
    emp.setLastName("Juguet");
    emp.setSalary(4000);

    employeeRepository.save(emp);

    assertThatThrownBy(() -> employeeRepository
      .findEmployeeFullNameById(1))
      .isInstanceOfAny(ConverterNotFoundException.class)
      .hasMessageContaining("No converter found capable of converting from type" 
        + "[com.baeldung.spring.data.noconverterfound.models.Employe");
}

이렇게 하면 ConverterNotFoundException 예외가 발생한다.

The root cause of the exception is that JpaRepository expects that its derived queries return an instance of the Employee entity class. Since the method returns an EmployeeFullName object, Spring Data JPA fails to find a converter suitable to convert the expected Employee object to the new EmployeeFullName object.

JPA는 엔티티 객체를 반환하는 방식이라서 그렇다고 한다. 그래서, 다른 DTO로 변경하려고 할 때 적절한 컨버터가 없어서 예외가 발생한다.

public EmployeeFullName(String firstName, String lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
}

//생성자 방식의 프로젝션

public interface IEmployeeFullName {
    String getFirstName();

    String getLastName();

    default String fullName() {
        return getFirstName().concat(" ")
          .concat(getLastName());
    }
}

인터페이스 방식의 프로젝션

이렇게 해서 문제를 해결할 수 있다고한다.

이 부분이 너무 궁금해서 디버깅을 통해서 과정을 살펴보기로 했다.

먼저 AbstractJpaQuery에서 doExcute를 통해서 쿼리를 실행하고 결과를 가져온다.

그렇게 JpaQueryExcution에서 쿼리를 excute하고 실제로 결과를 가져온다. 여기서 결과는 Object타입으로 나온다.

ResultProcessor에서 projection 부분이 null로 반환된다.

타깃을 컨버팅하는 부분에서 계속 예외가 발생하고 있다.

쭉 타고 들어가면 컨버팅하는 부분이 나온다.

여기서 반환타입이 인터페이스인경우와 그렇지 않은 경우가 있는데, 그렇지 않은 경우 Spring의 ConversionService을 사용해서 변환하는 거 같다.

그런데 현재 등록된 컨버터가 없어서 예외가 발생한다. 이 부분에 대한 이유를 찾기가 어려웠다.

Spring Boot Entity to DTO ConverterNotFoundException

이 글을 보니 스프링 컨버터 서비스가 적절한 컨버터를 찾지 못하는 경우도 있나보다. 그래서 이러한 경우에는 컨버터를 직접 등록해줘야 한다.

인터페이스를 활용한 DTO Projection은 위에서 isInterface()가 true로 걸려서 getProjectionTarget() 가 실행된다.

그 이후로

ProxyProjectionFactory에서 프로젝션을 만드는 작업이 시작된다.
인터페이스 기반의 프로젝션이 사용하기에는 더 편한 거 같다.

profile
답을 찾기 위해서 노력하는 사람

0개의 댓글