저번 포스팅에서는 Slick의 트랜잭션을 이용하는 법을 알아봤습니다.
복잡한 비즈니스 로직을 처리할 때 트랜잭션은 필수이고, 현업에서도 이전 포스팅의 주의사항들을 고려하면서 비즈니스 로직을 구현합니다. Slick을 사용하면서 발생가능한 문제인 속도 문제에 대해 알아보겠습니다.
Slick의 쿼리 해석
Slick은 데이터베이스 테이블과 엔티티를 마치 Scala의 Collection인 것처럼 코딩할 수 있게 도와주고, Repository 레이어의 로직과 다른 레이어에서 작성한 코드상의 위화감을 줄여주어 Scala 개발자가 적용하기 쉽습니다.
이전 포스팅에서도 설명했듯 Slick의 모든 쿼리 행동은 DBIOAction의 구현체이고, 컬럼이나 비교값 등의 표현은 Rep의 구현체입니다. 이 Rep 구현체들은 하나의 Node로 취급되고, 이러한 Node 들을 조합하고 해석하여 하나의 Tree를 만들고, 이 Tree를 재해석하여 하나의 Query가 만들어집니다. 이를 Abstract syntax tree라고 부르고, 줄여서 AST라고 합니다.
아래의 사진은 filter를 실행했을 때, result를 실행했을 때의 디버그 화면입니다.
filter1은 filter만 실행한 모습이고, 이는 Rep(Filter)로 해석됩니다.
filterResult는 filter1에 result를 실행한 모습이고, 이는 JdbcQueryActionExtensionMethodsImpl로 해석됩니다. (Jdbc로 실행해서 그렇습니다)
filter의 결과는 Query 타입으로 나오고, Query 타입은 QueryBase를 상속하였고, 이 타입은 다시 Rep를 상속하였습니다. Rep에는 toNode 함수가 존재하고, toNode 함수를 통해 AST(Abstract syntax tree)를 구성합니다.
result 함수는 AST 형태로 구성된 객체를 읽고 이 쿼리의 실행 결과를 가져오는 것입니다.
// Jdbc Query 구현체 일부분
class JdbcQueryActionExtensionMethodsImpl[R, S <: NoStream](tree: Node, param: Any)
extends BasicQueryActionExtensionMethodsImpl[R, S] {
def result: ProfileAction[R, S, Effect.Read] = {
def findSql(n: Node): String = n match {
case c: CompiledStatement => c.extra.asInstanceOf[SQLBuilder.Result].sql
case ParameterSwitch(cases, default) =>
findSql(cases.find { case (f, n) => f(param) }.map(_._2).getOrElse(default))
}
// 트리 파싱
(tree match {
case (rsm @ ResultSetMapping(_, compiled, CompiledMapping(_, elemType))) :@ (ct: CollectionType) =>
val sql = findSql(compiled)
new StreamingInvokerAction[R, Any, Effect] { streamingAction =>
protected[this] def createInvoker(sql: Iterable[String]): Invoker[Any] = createQueryInvoker(rsm, param, sql.head)
정리해보면 Slick의 쿼리는 Rep를 통해 Column이나 Column에 수행하는 액션들을 표현하고, 이 Rep를 통해 Node를 만들어 AST를 구성하고, 이 AST를 읽어들여 쿼리를 재구성하고 실행하는 모습입니다.
모든 구성 요소를 추상적으로 감싸고 타입으로 표현하는 모습을 보면 왜 라이브러리 소개를 Functional Relational Mapping for Scala 라고 하였는지 알 수 있습니다.
Slick의 쿼리 속도 문제
Slick을 사용하는 입장에서는 AST니 Rep니 따지지 않고 Collection처럼 데이터베이스를 다뤄서 코드만 짜면 되니 사용자에게 무척 편리한 도구처럼 보입니다. 하지만 편리함은 항상 부작용을 가져오니 이를 알아야 제대로 코드를 짤 수 있습니다. 그렇다면 Slick에서 고려해야할 문제는 무엇일까요? 바로 속도 문제입니다.
아래의 코드는 MyTable 테이블에서 id가 1인 entity를 조회해오는 성능을 측정하는 테스트 코드입니다.
class SlickTest extends AnyFunSuite {
class MyTable(tag: Tag) extends Table[(Int, String, Option[String], Double)](tag, None, "MyTable") {
def id = column[Int]("id", O.PrimaryKey)
def myString = column[String]("myString")
def optString = column[Option[String]]("optString")
def price = column[Double]("PRICE")
def * = (id, myString, optString, price)
}
val myTable = TableQuery[MyTable]
test("Query speed test") {
implicit val ec: ExecutionContextExecutor = ExecutionContext.global
val db = Database.forConfig("h2mem1")
Await.result(
db.run {
DBIO.seq(
myTable.schema.create,
myTable += (1, "foo", Some("bar"), 23.45),
myTable += (2, "bar", None, 23.45),
myTable += (3, "baz", None, 23.45),
)
},
Duration.Inf
)
val startTime = System.currentTimeMillis()
val res = Await.result(db.run { myTable.filter(_.id === 1).result.headOption }, Duration.Inf)
val endTime = System.currentTimeMillis()
println(s"res: ${res}")
println(s"time: ${endTime - startTime} ms")
}
}
속도는 얼마나 걸렸을까요? 단순히 id가 1인 정보를 조회해오니까 컴퓨터 속도에 따라 차이는 있지만 5ms 내에 조회해올까요?
[DEBUG] [04/30/2024 12:01:40.246] [SLICK] [slick.jdbc.JdbcBackend.benchmark] Execution of prepared statement took 2ms
[DEBUG] [04/30/2024 12:01:40.255] [SLICK] [slick.jdbc.StatementInvoker.result] /----+----------+-----------+-------\
[DEBUG] [04/30/2024 12:01:40.255] [SLICK] [slick.jdbc.StatementInvoker.result] | 1 | 2 | 3 | 4 |
[DEBUG] [04/30/2024 12:01:40.255] [SLICK] [slick.jdbc.StatementInvoker.result] | id | myString | optString | PRICE |
[DEBUG] [04/30/2024 12:01:40.255] [SLICK] [slick.jdbc.StatementInvoker.result] |----+----------+-----------+-------|
[DEBUG] [04/30/2024 12:01:40.255] [SLICK] [slick.jdbc.StatementInvoker.result] | 1 | foo | bar | 23.45 |
[DEBUG] [04/30/2024 12:01:40.255] [SLICK] [slick.jdbc.StatementInvoker.result] \----+----------+-----------+-------/
res: Some((1,foo,Some(bar),23.45))
time: 60 ms
로그를 보면 쿼리의 실제 실행까지는 2ms 밖에 걸리지 않는데, 전체적인 수행 시간은 놀랍게도 60ms로 30배나 더 걸렸습니다. 쿼리도 간단하고 데이터도 몇 개 없는데 대체 왜 이런걸까요? 답은 Slick 쿼리의 실행시간 파싱입니다.
Slick Query Compiler
위에서 얘기했듯 Slick의 쿼리는 AST 형태로 구현되고, 이는 Slick의 컴파일러가 런타임에 객체를 해석하고 쿼리를 만들어 실행하는 형태로 구현되어 있습니다. Slick의 컴파일러들은 slick github에서 확인할 수 있습니다.
slick은 3.1 이전까지만 해도 매우 복잡한 형태로 쿼리를 컴파일하고 있었지만, 3.1부터 개선이 이루어져 효율적으로 바뀌었습니다. 하지만 여전히 수많은 compiler들과 복잡한 내부 로직, AST의 런타임 해석이라는 문제가 존재하고, 이때문에 실제 DB query 시간과 application 레벨에서의 query 실행 시간에 차이가 존재하게 되는 것입니다.
이 문제는 slick의 초창기부터 존재하던 문제로, slick에서는 이를 해결하기 위해 Compiled 라는 함수를 제공해줍니다.
Compiled query
Slick이 제공해주는 쿼리 구조는 런타임에 해석됩니다. 단순히 table.filter(_.id === 1) 이라는 코드만 보아도 이는 Rep 객체로 만들어지고, 이 객체에 where 조건이나 table 정보가 저장되어 있으며, 이를 실행시간에 해석하여 쿼리를 만드니 어떻게 최적화해도 부가적인 공정이 들어가 느려지게 됩니다. Slick은 이를 해결하기 위해 쿼리를 미리 컴파일하여 런타임 해석 문제를 우회합니다.
Compiled query의 활용 예는 다음과 같습니다. (document에서 자세한 사항을 확인할 수 있습니다)
// 기존 예시와 이어짐
val compiledFilter = Compiled { myTable.filter(_.id === 1) }
val startTime2 = System.currentTimeMillis()
val res2 = Await.result(db.run { compiledFilter.result.headOption }, Duration.Inf)
val endTime2 = System.currentTimeMillis()
println(s"res2: ${res2}")
println(s"time: ${endTime2 - startTime2} ms")
쿼리를 미리 컴파일하니 런타임 해석에 사용하는 시간과 용량이 줄고, 이를 캐시처럼 여겨 재활용하니 속도가 더욱 빨라질거라 예상할 수 있습니다. 하지만 과연 그럴까요?
실제로 쿼리를 실행해보면 기존과 실행시간에 차이가 존재하지 않음을 알 수 있습니다. 왜 그럴까요?
[DEBUG] [04/30/2024 12:38:42.796] [SLICK] [slick.jdbc.JdbcBackend.benchmark] Execution of prepared statement took 1ms
[DEBUG] [04/30/2024 12:38:42.805] [SLICK] [slick.jdbc.StatementInvoker.result] /----+----------+-----------+-------\
[DEBUG] [04/30/2024 12:38:42.805] [SLICK] [slick.jdbc.StatementInvoker.result] | 1 | 2 | 3 | 4 |
[DEBUG] [04/30/2024 12:38:42.805] [SLICK] [slick.jdbc.StatementInvoker.result] | id | myString | optString | PRICE |
[DEBUG] [04/30/2024 12:38:42.805] [SLICK] [slick.jdbc.StatementInvoker.result] |----+----------+-----------+-------|
[DEBUG] [04/30/2024 12:38:42.805] [SLICK] [slick.jdbc.StatementInvoker.result] | 1 | foo | bar | 23.45 |
[DEBUG] [04/30/2024 12:38:42.805] [SLICK] [slick.jdbc.StatementInvoker.result] \----+----------+-----------+-------/
res2: Some((1,foo,Some(bar),23.45))
time: 59 ms
문제는 현재 쿼리가 너무 간단하다는 것입니다. 조건도 단순히 where 조건 하나 뿐이라서 런타임 해석에 오래 걸리지도 않고, 파라미터 바인딩 조건도 적어서 미리 compile 하는게 그렇게 유의미하지 않습니다. Compiled 쿼리는 쿼리 조건이 복잡하고 런타임 해석이 오래 걸릴경우 유효한 해결책입니다.
Compiled query의 한계 - 배열 조건
Compiled query도 완벽하지 않아 한가지 치명적인 제약사항이 있습니다. 바로 배열이나 리스트 형태의 여러 값이 들어간 조건은 컴파일 할 수 없다는 것입니다. 이유를 단순히 설명하자면, SQL 쿼리를 만들 때 Prepared Statement 형태로 만들고 비교 조건을 ?로 표시하는데, 배열은 갯수가 컴파일 시간에 정해지지 않고 런타임에 정해지니 몇 개의 ?를 넣을지 알 수 없기 때문입니다. 따라서 Compiled query를 사용하고 싶다면 배열 조건을 사용하지 않고 쿼리를 만들어야 합니다.
이를 우회하는 방법역시 존재하는데 배열 조건을 컴파일 타임에 알 수 없다면 배열의 갯수마다 if 조건으로 쿼리를 만들면 됩니다. scala는 pattern matching이라는 우아한 방법이 있으니 이를 활용해 아래처럼 사용하면 됩니다.
import slick.ast.BaseTypedType
import slick.lifted.{Query, Rep}
import slick.jdbc.H2Profile.api._
class ConvertFilterSeqConditionToEquals[E, U, C[_], T, K, V](prevQuery: Query[E, U, C])(column: E => Rep[V], afterQuery: Query[E, U, C] => Query[T, K, C])(implicit baseTypedType: BaseTypedType[V]) {
private def mergeConditions(col: Rep[V])(cons: Rep[V]*): Rep[Boolean] = cons.map(col === _).reduce(_ || _)
private val p1 = Compiled { (v1: Rep[V]) => afterQuery(prevQuery.filter(v => mergeConditions(column(v))(v1))) }
private val p2 = Compiled { (v1: Rep[V], v2: Rep[V]) => afterQuery(prevQuery.filter(v => mergeConditions(column(v))(v1, v2))) }
private val p3 = Compiled { (v1: Rep[V], v2: Rep[V], v3: Rep[V]) => afterQuery(prevQuery.filter(v => mergeConditions(column(v))(v1, v2, v3))) }
private val p4 = Compiled { (v1: Rep[V], v2: Rep[V], v3: Rep[V], v4: Rep[V]) => afterQuery(prevQuery.filter(v => mergeConditions(column(v))(v1, v2, v3, v4))) }
private val p5 = Compiled { (v1: Rep[V], v2: Rep[V], v3: Rep[V], v4: Rep[V], v5: Rep[V]) => afterQuery(prevQuery.filter(v => mergeConditions(column(v))(v1, v2, v3, v4, v5))) }
private val p6 = Compiled { (v1: Rep[V], v2: Rep[V], v3: Rep[V], v4: Rep[V], v5: Rep[V], v6: Rep[V]) => afterQuery(prevQuery.filter(v => mergeConditions(column(v))(v1, v2, v3, v4, v5, v6))) }
private val p7 = Compiled { (v1: Rep[V], v2: Rep[V], v3: Rep[V], v4: Rep[V], v5: Rep[V], v6: Rep[V], v7: Rep[V]) => afterQuery(prevQuery.filter(v => mergeConditions(column(v))(v1, v2, v3, v4, v5, v6, v7))) }
// 필요한 갯수만큼 늘린다 (tuple 최대 22개)
def apply(conditions: Seq[V]) = conditions match {
case Seq(v1) => p1(v1)
case Seq(v1, v2) => p2(v1, v2)
case Seq(v1, v2, v3) => p3(v1, v2, v3)
case Seq(v1, v2, v3, v4) => p4(v1, v2, v3, v4)
case Seq(v1, v2, v3, v4, v5) => p5(v1, v2, v3, v4, v5)
case Seq(v1, v2, v3, v4, v5, v6) => p6(v1, v2, v3, v4, v5, v6)
case Seq(v1, v2, v3, v4, v5, v6, v7) => p7(v1, v2, v3, v4, v5, v6, v7)
case _ => throw new SlickException("Too many conditions in 'convertFilterSeqConditionToEquals'")
}
}
// 첫 인자로 기존 쿼리를 조합하거나 테이블을 그대로 사용 가능합니다
// new ConvertFilterSeqConditionToEquals(myTable.filter(_.id === 1))(_.id, identity).apply(ids)
// 마지막 인자로 비교 이후 신규 조건들을 이어갈 수 있습니다
// new ConvertFilterSeqConditionToEquals(myTable)(_.id, _.filter(_.price === 100.0))
def findByIdsCompiled(ids: Seq[Int]) =
new ConvertFilterSeqConditionToEquals(myTable)(_.id, identity).apply(ids)
db.run {
findByIdsCompiled(Seq(1, 2)).result.headOption
}
위의 코드로 조건을 우회할 수 있지만, 배열의 갯수만큼 계속 구현을 늘려줘야 하기 때문에 근본적인 해결책은 될 수 없습니다.
Native Query
Compiled query를 사용하거나 기존 쿼리를 그대로 사용하거나 근본적인 문제는 해결되지 않습니다. 쿼리 수행 속도가 중요하다면 결국 Native query를 사용할 수 밖에 없습니다.
Native Query에 대해 자세히 알고 싶으면 Document를 보시면 됩니다.
val startTime2 = System.currentTimeMillis()
val res2 = Await.result(db.run {
sql"""select * from "MyTable" where "id" = 1""".as[(Int, String, Option[String], Double)].head
}, Duration.Inf)
val endTime2 = System.currentTimeMillis()
println(s"res2: ${res2}")
println(s"time: ${endTime2 - startTime2} ms")
[DEBUG] [04/30/2024 13:53:06.294] [SLICK] [slick.jdbc.JdbcBackend.statementAndParameter] Executing prepared statement: prep4: select * from "MyTable" where "id" = 1
[DEBUG] [04/30/2024 13:53:06.296] [SLICK] [slick.jdbc.JdbcBackend.benchmark] Execution of prepared statement took 1ms
[DEBUG] [04/30/2024 13:53:06.304] [SLICK] [slick.jdbc.StatementInvoker.result] /----+----------+-----------+-------\
[DEBUG] [04/30/2024 13:53:06.304] [SLICK] [slick.jdbc.StatementInvoker.result] | 1 | 2 | 3 | 4 |
[DEBUG] [04/30/2024 13:53:06.304] [SLICK] [slick.jdbc.StatementInvoker.result] | id | myString | optString | PRICE |
[DEBUG] [04/30/2024 13:53:06.304] [SLICK] [slick.jdbc.StatementInvoker.result] |----+----------+-----------+-------|
[DEBUG] [04/30/2024 13:53:06.304] [SLICK] [slick.jdbc.StatementInvoker.result] | 1 | foo | bar | 23.45 |
[DEBUG] [04/30/2024 13:53:06.304] [SLICK] [slick.jdbc.StatementInvoker.result] \----+----------+-----------+-------/
res2: (1,foo,Some(bar),23.45)
time: 22 ms
결과를 보면 기존의 60ms 걸리던 작업이 22ms로 3배 빨라진 것을 알 수 있습니다.
요약
Slick은 AST 구조체를 만들어 이를 런타임에 해석해 쿼리를 만듭니다. 이 작업은 생각보다 시간이 걸리는 작업이라 원하는 만큼의 성능이 나오지 않을 수 있습니다. 이를 위해 Compiled query가 존재하며, 컴파일 타임에 쿼리를 미리 만들어 둘 수 있습니다.
Compiled query는 동적으로 변경되는 조건인 Array 조건을 해석할 수 없으므로 이를 우회하려면 따로 코드를 작성해줘야 합니다. 다만 이 방법 역시 완벽한 해결책이 아니므로 근본적인 문제는 해결할 수 없습니다.
batch insert 와 같이 성능이 중요한 작업이라면 Native query를 사용해서 처리해야 합니다.