JPA를 사용하면서 SQL function을 사용하고 싶은 경우가 생길 수가 있습니다. 예제를 통해서 필요한 상황과 해결 방법을 알아보겠습니다.
간단한 예제를 하나 만들어 보겠습니다. 게시물을 관리하는 테이블(article)이 있고 게시물에는 여러 tag를 설정할 수 있습니다. 태그는 별도의 활용 가치는 없고 단순 노출을 목적으로 한다면 간단하게 json 컬럼으로 여러 태그 정보를 관리할 수 있을 것입니다.
create table article
(
article_no int not null auto_increment comment '일련번호',
title varchar(255) not null comment '제목',
contents varchar(4000) not null comment '내용',
tags json null comment '태그',
created_at timestamp not null default CURRENT_TIMESTAMP comment '등록일시',
primary key (`article_no`),
index article_idx (title, contents)
) engine = InnoDB default charset = utf8mb4 comment '게시물';
테이블 구조에 맞게 예제 데이터를 등록하고
insert into article(title, contents, tags, created_at) values
('제목1', '내용1', '["맛집", "가성비"]', now()),
('제목2', '내용2', '["힐링"]', now());
Entity를 만들어서
@Entity
@Table(name = "article")
class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val articleNo: Int = 0
var title: String = ""
private set
var contents: String = ""
private set
var tags: String? = null
private set
@CreationTimestamp
val createdAt: LocalDateTime = LocalDateTime.MIN
}
간단하게 테스트 코드를 작성해서 실행하면 성공합니다.
@Test
fun testSelect() {
val list = articleRepository.findAll()
assert(list.size == 2)
}
AttributeConverter
tags는 JSON Array 구조이기 때문에 entity에서 String 타입으로 관리하는 것보다 List
tags가 DB에 저장될 때는 String으로, 객체로 만들 때는 List
class StringListConverter : AttributeConverter<List<String>?, String?> {
private val mapper = ObjectMapper()
override fun convertToDatabaseColumn(attribute: List<String>?): String? {
return attribute?.let { mapper.writeValueAsString(it) }
}
override fun convertToEntityAttribute(dbData: String?): List<String>? {
return mapper.readValue(dbData, jacksonTypeRef<List<String>>())
}
}
String 타입으로 정의했던 tags도 List
@Convert(converter = StringListConverter::class)
var tags: List<String>? = null
private set
다시 테스트 코드를 실행해 보면 성공합니다.
타입 변경으로 인한 문제
아무 문제 없이 잘 사용하고 있던 어느 날, 게시물을 조회할 때 tag를 검색 조건에 포함하는 요구 사항이 발생합니다. tags는 AttributeConverter를 통해서 List
몇 가지 케이스로 확인해보겠습니다.
@Query
List
@Query("""
select a
from Article a
where a.tags like :tags
""")
fun findByTags(@Param("tags") tags: List<String>): List<Article>
Specification
Specification도 equals, like 모두 동작 하지 않습니다.
@Test
fun testSelect() {
val list = articleRepository.findAll { root, _, cb ->
cb.like(root.get<String>(Article::tags.name), "맛집")
}
}
QueryDSL
queryDSL 역시 eq, contains 모두 동작하지 않습니다
override fun getArticle(tags: List<String>): List<Article> {
return from(article)
.where(BooleanBuilder().apply {
this.and(article.tags.contains(tags))
})
.fetch()
}
tags의 타입이 LIst
MetadataBuilderContributor
MySQL의 JSON_CONTAINS
함수를 JPA에서 사용할 수 있으면 좋을 것 같아 검색을 했더니, 이 문제를 해결할 수 있는 좋은 글을 찾았습니다. Hibernate 5.2.18부터 MetadataBuilderContributor라는 인터페이스를 사용할 수 있고 이 것을 구현하면 SQL Function을 추가할 수 있습니다.
Dialect에 추가하는 방법도 있지만, Dialect를 수정하거나 버전을 변경할 경우 같이 챙겨서 수정해야 하기에 추천하지 않는다고 합니다.
class SqlFunctionsMetadataBuilderContributor : MetadataBuilderContributor {
override fun contribute(metadataBuilder: MetadataBuilder) {
metadataBuilder.applySqlFunction("JSON_CONTAINS",
StandardSQLFunction("JSON_CONTAINS", StandardBasicTypes.BOOLEAN))
}
}
새로 만든 SqlFunctionsMetadataBuilderContributor를 Spring 설정 파일에 추가합니다.
spring.jpa.properties.hibernate.metadata_builder_contributor=com.example.SqlFunctionsMetadataBuilderContributor
해결 방법
SqlFunctionsMetadataBuilderContributor 추가로 이제 JSON_CONTAINS
를 사용할 수 있게 됐습니다. 앞서 살펴 봤던 케이스별로 적용방법을 확인해보겠습니다.
@Query
tags 파라미터에 json array 형태의 문자열을 전달하는 방법입니다.
@Query("""
select a
from Article a
where function('JSON_CONTAINS', a.tags, :tags) = true
""")
fun getArticle(@Param("tags") tags: String): List<Article>
위 예제는 파라미터를 String으로 전달하고 있습니다.
아무래도 List
@Query("""
select a
from Article a
where function('JSON_CONTAINS', a.tags, :#{@objectMapper.writeValueAsString(#tags)}) = true
""")
fun getArticle3(@Param("tags") tags: List<String>): List<Article>
Specification
간단하게 테스트 코드를 만들어서 실행해보면 성공합니다.
@Test
fun testSelect() {
val mapper = ObjectMapper()
val list = articleRepository.findAll { root, _, cb ->
cb.conjunction().apply {
expressions.add(
cb.equal(
cb.function(
"JSON_CONTAINS",
Boolean::class.java,
root.get<List<String>>(Article::tags.name),
cb.literal(mapper.writeValueAsString(listOf("힐링"))),
), true
)
)
}
}
}
QueryDSL
QueryDSL도 역시 잘 됩니다.
override fun getArticle(tags: List<String>): List<Article> {
return from(article)
.where(BooleanBuilder().apply {
this.and(Expressions.booleanTemplate("JSON_CONTAINS({0}, {1}) = true",
article.tags, mapper.writeValueAsString(tags)))
})
.fetch()
}
결론
Entity 필드 타입은 도메인에 최대한 맞는 것으로 선택하고 SQL Function이 필요하다면 JPA에서 Native Query 없이도 사용할 수 있습니다.
댓글남기기