[Spring] JPA 동적 네이티브 쿼리, 제대로 알고 사용하자
문제 상황
프로젝트를 수행하면서 다음과 같은 상황이었다.
cafe, beverage, document 세 엔티티에 대해 cafe : beverage = 1 : n 관계였으며, beverage : document = 1 : 1 관계를 갖는다.
세 개의 엔티티를 조인하고 특정 컬럼만 뽑아내기 위해 직접 CafeRepository에 다음과 같이 nativeQuery를 날린 상황이었다.
package com.alpha.DLINK.domain.cafe.repository;
import com.alpha.DLINK.domain.cafe.domain.Cafe;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface CafeRepository extends JpaRepository<Cafe, Long> {
@Query(value = "SELECT b.beverage_id, d.content FROM Cafe c " +
"JOIN Beverage b ON c.cafe_id = b.cafe_id " +
"JOIN Document d ON b.document_id = d.document_id " +
"WHERE :conditions", nativeQuery = true)
List<Object[]> findBeverageIdAndDocumentByConditions(@Param("conditions") String conditions);
}
Cafe List에서 name과 latitude, longitude가 일치하는 것들만 데이터를 가져오는 쿼리가 필요해서 메인 서비스에서 직접 서브쿼리를 생성하여 해당 query문에 넣어주었다.
MainService.java
// 동적 네이티브 쿼리 생성
List<String> conditions = cafes.stream()
.map(cafe -> String.format("(c.name = '%s' AND c.latitude = '%s' AND c.longitude = '%s')",
cafe.getName(), cafe.getLatitude(), cafe.getLongitude())).toList();
String conditionsString = String.join(" OR ", conditions);
List<QueryResponseDTO> list = cafeRepository.findBeverageIdAndDocumentByConditions(conditionsString).stream().map(QueryResponseDTO::new).toList();
그랬더니 결과는 다음과 같았다.
2024-05-22T02:14:08.373+09:00 DEBUG 2667 --- [nio-8080-exec-2] org.hibernate.SQL :
SELECT
b.beverage_id,
d.content
FROM
Cafe c
JOIN
Beverage b
ON c.cafe_id = b.cafe_id
JOIN
Document d
ON b.document_id = d.document_id
WHERE
(
?
)
Hibernate가 직접 작성한 조건 서브쿼리를 인식하지 못하는 문제가 발생한 것이다!
이를 어떻게 해야하나 곰곰히 생각해보고 검색해본 결과, 네이티브 쿼리를 사용할 때 조건을 문자열로 전달하는 방식은 SQL 인젝션의 위험이 있으므로 안전하게 처리해야 한다는 것을 알았다.
해결 방안
따라서 CafeRepositoryCustom 이라는 interface를 생성하고 해당 인터페이스의 구현체를 생성했다.
public interface CafeRepositoryCustom {
List<Object[]> findBeverageIdAndDocumentByConditions(String conditions);
}
@Repository
public class CafeRepositoryCustomImpl implements CafeRepositoryCustom {
@PersistenceContext
private EntityManager entityManager;
@Override
public List<Object[]> findBeverageIdAndDocumentByConditions(String conditions) {
String sql = "SELECT b.beverage_id, d.content FROM Cafe c " +
"JOIN Beverage b ON c.cafe_id = b.cafe_id " +
"JOIN Document d ON b.document_id = d.document_id " +
"WHERE " + conditions;
Query query = entityManager.createNativeQuery(sql);
return query.getResultList();
}
}
이후 사용하고자 하는 Spring Data JPA 구현체에 해당 인터페이스를 상속한다.
import org.springframework.data.jpa.repository.JpaRepository;
public interface CafeRepository extends JpaRepository<Cafe, Long>, CafeRepositoryCustom {
// 다른 쿼리 메서드 정의
}
이렇게 변경하고 테스트해보았더니 정상적으로 작동했다!
Spring은 파도 파도 끝이 없는 것 같다. 공부할 것이 늘었다.
<추가> 트러블 슈팅
위의 동적 네이티브 쿼리 작성 시 로컬 환경에서는 정상 작동해도 EC2에서 다음과 같은 에러가 발생했다.
java.sql.SQLSyntaxErrorException: Table 'alpha.Cafe' doesn't exist
at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:121) ~[mysql-connector-j-8.3.0.jar!/:8.3.0]
at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:122) ~[mysql-connector-j-8.3.0.jar!/:8.3.0]
at com.mysql.cj.jdbc.ClientPreparedStatement.executeInternal(ClientPreparedStatement.java:912) ~[mysql-connector-j-8.3.0.jar!/:8.3.0]
at com.mysql.cj.jdbc.ClientPreparedStatement.executeQuery(ClientPreparedStatement.java:968) ~[mysql-connector-j-8.3.0.jar!/:8.3.0]
at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeQuery(ProxyPreparedStatement.java:52) ~[HikariCP-5.0.1.jar!/:na]
at com.zaxxer.hikari.pool.HikariProxyPreparedStatement.executeQuery(HikariProxyPreparedStatement.java) ~[HikariCP-5.0.1.jar!/:na]
알고보니 동적 네이티브 쿼리에서 JPQL 문법처럼 table 명을 엔티티 명으로 써버린 것이다!
@Repository
public class CafeRepositoryCustomImpl implements CafeRepositoryCustom {
@PersistenceContext
private EntityManager entityManager;
@Override
public List<Object[]> findBeverageIdAndDocumentByConditions(String conditions) {
String sql = "SELECT b.beverage_id, d.content FROM cafe c " +
"JOIN beverage b ON c.cafe_id = b.cafe_id " +
"JOIN document d ON b.document_id = d.document_id " +
"WHERE " + conditions;
Query query = entityManager.createNativeQuery(sql);
return query.getResultList();
}
}
그래서 다음과 같이 테이블 명을 전부 소문자로 변경해보았다.