Backend/Ktor

[Ktor] Exposed로 DAO 스타일 엔티티, DSL 스타일 쿼리 제작하기

mopil 2023. 7. 21. 16:10
반응형

지난글에 이어 이번에는 Exposed로 DAO 스타일 엔티티와 DSL 스타일 쿼리를 작성해본다.

그리고 공통 엔티티에 사용되는 BaseEntity를 작성하는 방법을 공유한다.

 

소스코드

https://github.com/mopil/ktor-server

 

GitHub - mopil/ktor-server: HikariCP + MySQL + Exposed (DAO) + Koin

HikariCP + MySQL + Exposed (DAO) + Koin. Contribute to mopil/ktor-server development by creating an account on GitHub.

github.com

 

Exposed 위키

https://github.com/JetBrains/Exposed/wiki

 

Home

Kotlin SQL Framework. Contribute to JetBrains/Exposed development by creating an account on GitHub.

github.com

 

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
        )
    }
}
반응형