[Ktor] Exposed로 DAO 스타일 엔티티, DSL 스타일 쿼리 제작하기
지난글에 이어 이번에는 Exposed로 DAO 스타일 엔티티와 DSL 스타일 쿼리를 작성해본다.
그리고 공통 엔티티에 사용되는 BaseEntity를 작성하는 방법을 공유한다.
소스코드
https://github.com/mopil/ktor-server
Exposed 위키
https://github.com/JetBrains/Exposed/wiki
Exposed 의존성은 다음과 같이 설정해야한다.
implementation("org.jetbrains.exposed:exposed-core:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-java-time:$exposedVersion")
버전은 Exposed 공식 깃허브의 latest 릴리즈 버전을 사용하면 된다.
# BaseEntity
생성일자와 수정일자 처럼 공통적으로 포함되는 칼럼은 공통 엔티티 객체로 관리하는 것이 편리하다.
스프링에서 @MappedSuperclass로 구현했던 공통 엔티티를 Exposed에서는 다음과 같이 구현하면 된다.
abstract class BaseLongIdTable(name: String, idName: String = "id") : LongIdTable(name, idName) {
val createdAt = datetime("created_at").clientDefault { LocalDateTime.now() }
val updatedAt = datetime("updated_at").clientDefault { LocalDateTime.now() }
}
abstract class BaseEntity(id: EntityID<Long>, table: BaseLongIdTable) : LongEntity(id) {
val createdAt by table.createdAt
var updatedAt by table.updatedAt
}
abstract class BaseEntityClass<E : BaseEntity>(table: BaseLongIdTable) : LongEntityClass<E>(table) {
private val log = logger()
init {
EntityHook.subscribe { action ->
if (action.changeType == EntityChangeType.Updated) {
try {
action.toEntity(this)?.updatedAt = LocalDateTime.now()
} catch (e: Exception) {
log.warn("Failed to update entity $this updatedAt", e.message)
}
}
}
}
}
여기서 EntityHook이 JPA의 @PreUpdated와 동일한 기능을 한다.
# User Entity
object Users : BaseLongIdTable("users", "user_id") {
val name = varchar("user_name", 255)
val age = integer("age")
val balance = integer("balance").default(0)
}
class User(id: EntityID<Long>) : BaseEntity(id, Users) {
companion object : BaseEntityClass<User>(Users)
var name by Users.name
var age by Users.age
var balance by Users.balance
}
Exposed의 DAO스타일로 엔티티를 작성하려면 먼저 복수형(Users)로 테이블 명세를 해주고, 단수형(User)로 엔티티를 정의해주면 된다. (아무래도 공식 위키에도 이렇게 적혀있는걸 보니 이게 컨벤션인 듯 하다)
이때 우리가 위에서 커스텀으로 제작한 BaseEntity를 사용해준다.
# UserRepository
interface UserRepository {
suspend fun save(request: CreateUserRequest): User
suspend fun findById(id: Long): User
}
class UserRepositoryImpl : UserRepository {
override suspend fun save(request: CreateUserRequest) = dbQuery {
User.new {
this.name = request.name
this.age = request.age
}
}
override suspend fun findById(id: Long) = dbQuery {
User.findById(id) ?: throw NoSuchElementException()
}
}
리포지토리는 다음과 같이 구성했다. Spring Data JPA와는 다르게 기본 CRUD 리포지토리 구현체를 제공해주지 않아서 직접 구현해야하는 번거로움이 조금 있다.
dbQuery는 트랜잭션을 처리하도록 (@Transactional과 동일한 역할) 커스텀으로 제작한 고차함수다. Exposed가 기본으로 제공하는 transcation 고차함수를 활용해도 무방하다.
# DSL 스타일로 동적 쿼리
비슷하게 product라는 예시 엔티티를 선언한다.
object Products : BaseLongIdTable("product", "product_id") {
val name = varchar("product_name", 255)
val category = enumerationByName<ProductCategory>("category", 255)
val description = varchar("description", 255)
val price = integer("price")
}
class Product(id: EntityID<Long>) : BaseEntity(id, Products) {
companion object : BaseEntityClass<Product>(Products)
var name by Products.name
var category by Products.category
var description by Products.description
var price by Products.price
}
여담으로 enumerationByName을 사용하면 JPA 엔티티에서 @Enumerated(EnumType.String)을 사용한 것과 동일하게 동작한다. enumeration만 하면 enum의 순서값이 들어간다.
제품을 검색하는 쿼리 작성하기
ProductRepository에는 findAllByCondition()이라는 검색 API를 제작했다.
override suspend fun findAllByCondition(request: GetProductRequest): PageResponse<GetProductResponse> = dbQuery {
val query = Products.selectAll()
request.name?.let { query.andWhere { Products.name like it } }
request.price?.let {
when (request.priceCondition) {
ProductPriceCondition.LESS_THAN -> query.andWhere { Products.price less it }
ProductPriceCondition.GREATER_THAN -> query.andWhere { Products.price greater it }
else -> query.andWhere { Products.price eq it }
}
}
request.category?.let { query.andWhere { Products.category eq it } }
val totalCount = query.count()
query.limit(request.limit, request.offset)
val list = query.map { Product.wrapRow(it) }.map { GetProductResponse(it) }
return@dbQuery list.toPageResponse(request.offset, request.limit, totalCount)
}
QueryDSL과 유사하게 작성하면 된다.
다만 Page 객체는 스프링에서만 지원하여 페이지네이션은 직접 제작해야한다.
여기서는 오프셋기반 페이지네이션을 제작해봤다. totalCount를 가져오는 query.count()의 호출 순서가 중요한데, 조건을 모두 타고 카운트를 세도록 설정해야한다.
주안점으로 잘 보면 Product(엔티티)가 아닌, Products(테이블)에서 바로 쿼리를 작성했다. 이게 Exposed의 DSL 스타일인데, 쿼리를 다 빌딩하고 마지막에 Product.wrapRow()를 통해서 엔티티로 매핑해주면 된다.
검색 조건은 다음과 같다.
data class GetProductRequest(
val name: String? = null,
val price: Int? = null,
val priceCondition: ProductPriceCondition? = null,
val category: ProductCategory? = null,
val offset: Long,
val limit: Int
) {
constructor(params: Parameters): this(
name = params["name"],
price = params["price"]?.toInt(),
priceCondition = params["priceCondition"]?.let { ProductPriceCondition.valueOf(it) },
category = params["category"]?.let { ProductCategory.valueOf(it) },
offset = params["offset"]?.toLong() ?: 0,
limit = params["limit"]?.toInt() ?: 10
)
}
Ktor는 쿼라파라미터를 바로 객체로 직렬화하는 것을 지원하지 않아서 이렇게 파라미터를 받아서 직접 생성해주도록 라우터에 제작했다.
fun Route.productRouter() {
val productService: ProductService by inject()
get(Uris.Product.GET_ALL_PRODUCTS) {
val params = GetProductRequest(call.parameters)
call.respond(productService.getAllProducts(params))
}
}
페이지네이션 유틸은 다음과 같이 제작했다.
object PaginationUtils {
data class PageResponse<T> (
val offset: Long,
val limit: Int,
val totalCount: Long,
val isLast: Boolean,
val data: List<T>
)
fun <T> List<T>.toPageResponse(offset: Long, limit: Int, totalCount: Long): PageResponse<T> {
return PageResponse(
offset = offset,
limit = limit,
totalCount = totalCount,
isLast = offset + limit >= totalCount,
data = this
)
}
}