Transaction
Slick에서 Transaction을 사용하고 싶다면 DBIOAction 타입에 transactionally 함수를 사용하면 됩니다.
더욱 자세한 사용법은 slick document를 읽어보시길 추천드립니다.
Slick의 트랜잭션에 대해 알아보기 전에 먼저 트랜잭션 없이 쿼리를 여러 건 실행하고 싶을 때는 어떻게 코드를 작성하면 되는지 알아보겠습니다.
Multiple query
Slick의 Query 타입들은 모두 DBIOAction을 상속받습니다.
DBIOAction 타입을 살펴보면 내부에 map, flatMap, withFilter, zip 등 다양한 함수들을 구현해 둔 것이 보입니다.
flatMap과 map, withFilter가 구현되어 있으므로 scala의 for-comprehension을 이용해 조합할 수 있음을 알 수 있습니다.
sealed trait DBIOAction[+R, +S <: NoStream, -E <: Effect] extends Dumpable {
/** Transform the result of a successful execution of this action. If this action fails, the
* resulting action also fails. */
def map[R2](f: R => R2)(implicit executor: ExecutionContext): DBIOAction[R2, NoStream, E] =
flatMap[R2, NoStream, E](r => SuccessAction[R2](f(r)))
/** Use the result produced by the successful execution of this action to compute and then
* run the next action in sequence. The resulting action fails if either this action, the
* computation, or the computed action fails. */
def flatMap[R2, S2 <: NoStream, E2 <: Effect](f: R => DBIOAction[R2, S2, E2])(implicit executor: ExecutionContext): DBIOAction[R2, S2, E with E2] =
FlatMapAction[R2, S2, R, E with E2](this, f, executor)
/** Creates a new DBIOAction with one level of nesting flattened, this method is equivalent
* to `flatMap(identity)`.
*/
def flatten[R2, S2 <: NoStream, E2 <: Effect](implicit ev : R <:< DBIOAction[R2,S2,E2]) = flatMap(ev)(DBIO.sameThreadExecutionContext)
...
}
이전 Slick 설명에서 Database 인스턴스의 run 함수를 통해 쿼리를 실행한다고 하였는데, 이렇게 실행된 쿼리는 실제로 Database에 질의를 수행한 것이기 때문에 다른 쿼리와 조합할 수 없습니다.
실제 타입 역시 Future[_] 타입이므로 실제로 실행된 함수임을 알 수 있습니다 (Scala의 Future는 생성 즉시 실행됩니다).
만약 여러 건의 쿼리를 같이 수행하고 싶다면 run 함수를 최대한 미루고 쿼리를 조합해야 합니다.
아래의 예시는 id가 1, 2인 entity를 검색하는 쿼리입니다.
/*
SELECT * FROM table WHERE id = 1
SELECT * FROM table WHERE id = 2
*/
val table = TableQuery[T]
db.run(
table.filter(_.id === 1)
.result
.flatMap { x =>
table.filter(_.id === 2)
.result
.map((x, _))
}
)
코드에 적힌 flatMap, map의 조합은 scala의 syntax-sugar인 for-comprehension에 의해서 아래처럼 축얄할 수 있습니다.
val table = TableQuery[T]
db.run {
for {
a <- table.filter(_.id === 1).result
b <- table.filter(_.id === 2).result
} yield (a, b)
}
flatMap, map이 구현되어 있어 훨씬 간단하게 사용 가능한 것을 알 수 있습니다 (withFilter도 구현되어 있어 if 역시 사용 가능합니다).
DBIOAction.transactionally
Slick도 데이터베이스 조작에서 가장 중요한 transaction을 지원해줍니다.
DBIOAction에 조합 가능한 함수로 정의되어 있으며, 정의는 아래와 같습니다.
def transactionally: DBIOAction[R, S, E with Effect.Transactional] = SynchronousDatabaseAction.fuseUnsafe(
StartTransaction.andThen(a).cleanUp(eo => if(eo.isEmpty) Commit else Rollback)(DBIO.sameThreadExecutionContext)
.asInstanceOf[DBIOAction[R, S, E with Effect.Transactional]]
)
코드를 살펴보고 알 수 있는 사실은 transactionally 작업이 transaction을 시작하고, 설정된 DB 작업을 처리하고, 어떤 에러도 없으면 현재 ExecutionContext에서 commit을 수행하거나 rollback을 수행한다는 것입니다.
slick document에는 transactionally에 대해 더욱 자세히 적혀있는 것을 볼 수 있는데 중요한 부분들만 적으면 아래와 같습니다
DBIOAction은 수많은 작업들이 조합되어 만들어지고, DB 작업 이외의 작업도 연결이 가능하며, 내부적으로 커넥션 풀을 사용하여 필요치 않은 상황에서는 커넥션을 자율적으로 해제하여 효율적으로 사용한다
DB 작업들로만 이루어진 DBIOAction은 효율적으로 작업을 수행하지만, 하나의 세션만 사용하는 부작용이 있다
transactionally를 사용하면 하나의 세션만 사용하게 되고 원자적으로 동작한다 (commit or rollback)
주의사항이 하나 있는데, Spring-data-jpa를 사용할경우 @Transactionally 어노테이션을 사용해서 새로운 트랜잭션을 시작하거나, 기존의 트랜잭션에 참가하는 등 트랜잭션을 다양하게 사용 가능한데 반해 Slick은 transactionally가 여러개 있어도 가장 바깥의 transactionally 하나만 적용된다는 사실입니다. 즉, 내부적으로 여러 transactionally가 중첩되어 있더라도 가장 바깥의 트랜잭션만 작동해서 내부의 transactionally는 기존의 트랜잭션에 참가만 한다고 여기면 됩니다.
만약 임의로 자체적인 롤백이 필요하다면 throw exception을 수행하듯 DBIO.failed를 반환하면 됩니다.
(for {
entity <- repository.update(???)
_ <- if (entity.isNotValid) DBIO.failed(new Exception("not valid")) else DBIO.successful(entity)
} yield entity).transactionally
다른 Future 작업들과 같이 사용해야 할 경우
실세계의 서비스는 예제와 달리 복잡하여 단순한 transaction만으로 처리하기 힘들 수도 있습니다. 예를들면 MongoDB에는 로그를 기록하고 MySQL에는 실제 데이터를 기록하는 서비스가 있을 수 있으며, 서로 이종의 데이터베이스를 조합해 사용한다면 이러한 insert 작업을 하나의 transaction으로 조합하기 어려울 수 있습니다.
이런 때를 위해 Slick은 DBIO.from이라는 함수를 제공해줍니다.
DBIO.from의 구현 예를 보면 아래와 같습니다.
/** Convert a `Future` to a [[DBIOAction]]. */
def from[R](f: Future[R]): DBIOAction[R, NoStream, Effect] = FutureAction[R](f)
...
/** An asynchronous DBIOAction that returns the result of a Future. */
case class FutureAction[+R](f: Future[R]) extends DBIOAction[R, NoStream, Effect] {
def getDumpInfo = DumpInfo("future", String.valueOf(f))
override def isLogged = true
}
주석을 읽어보면 알 수 있듯 DBIO.from은 단순히 Future를 DBIOAction으로 변환해주는 함수입니다.
위에서 상술했듯 transactionally는 조합된 DBIOAction을 하나의 transaction으로 묶어주는 함수이므로 DBIO.from을 사용하면 DB 작업 이외에 Future 작업도 transaction에 엮을 수 있습니다.
transaction issue 링크의 설명을 보면 DBIO.from은 non-database computation을 위한 함수임을 알 수 있습니다.
만약 transaction 실행 중 DBIO.from 액션에 실패했을경우 트랜잭션은 롤백되지만 DBIO.from이 수행하거나 이전에 수행한 작업들은 롤백되지 않습니다 (데이터베이스 작업이 아니니까요). 사람이 수동으로 어디서 실패했는지 확인하고 이를 되돌려놓는 작업이 필요할 수 있으니, 롤백 처리를 간단하게 하려면 데이터베이스 작업 외의 액션을 많이 엮지 않는 것이 좋습니다.
실제 사용을 한다면 아래처럼 사용할 수 있습니다.
(for {
baseEntity <- tableRepository.findEntityByIdQuery(id)
savedEntity <- tableRepository.updateQuery(entity.copy(str = "test"))
log <- DBIO.from(mongoRepository.saveLog(savedEntity)) // DBIO.run을 통해 실제로 수행한 쿼리를 건네줘야 함
} yield savedEntity).transactionally
Repository와 TransactionRepository 예제
트랜잭션의 예시로 entity를 수정하고 로그를 남기는 예제를 만들어보겠습니다.
H2Config 생성
데이터베이스는 테스트 용이므로 H2 데이터베이스를 사용합니다.
trait SlickH2Config {
import slick.jdbc.H2Profile.api._
val db = Database.forConfig("h2")
}
필요한 entity class와 table class 생성
MyItem 이라는 entity를 사용하고, 이를 MyItemTableComponent trait로 생성합니다.
case class MyItem(id: Int, myString: String, optString: Option[String], price: Double)
trait MyItemTableComponent extends SlickH2Config {
class MyItemTable(tag: Tag) extends Table[MyItem](tag, None, "MyItem") {
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).mapTo[MyItem]
}
protected val myItemTable = TableQuery[MyItemTable]
}
로그로서 사용하기 위해 MyLog라는 entity를 사용하고, 이를 MyLogTableComponent trait로 생성합니다.
case class MyLog(id: Int, myString: String, optString: Option[String], price: Double)
object MyLog {
def from(myItem: MyItem): MyLog =
MyLog(id = myItem.id, myString = myItem.myString, optString = myItem.optString, price = myItem.price)
}
trait MyLogTableComponent extends SlickH2Config {
class MyLogTable(tag: Tag) extends Table[MyLog](tag, None, "MyLog") {
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).mapTo[MyLog]
}
protected val myLogTable = TableQuery[MyLogTable]
}
필요한 Repository 생성
트랜잭션에 사용하기 위해서 각 테이블마다 repository를 생성합니다.
트랜잭션에서 사용하기 위해 실제로 쿼리를 실행하지 않고 DBIOAction을 반환하는 함수도 추가합니다.
// item repository
class MyItemRepository extends MyItemTableComponent {
def findById(id: Int): Future[MyItem] = db.run { findByIdQuery(id) }
def findByIdQuery(id: Int) = myItemTable.filter(_.id === id).result.head
def updateStrQuery(id: Int, myString: String) = db.run { updateQuery(id, myString) }
def updateQuery(id: Int, myString: String) = myItemTable.filter(_.id === id).map(_.myString).update(myString)
}
// log repository
class MyLogRepository extends MyLogTableComponent {
def saveQuery(entity: MyLog) =
myLogTable returning myLogTable.map(_.id) into ((e, id) => e.copy(id = id)) += entity
}
Transaction Repository 생성
위에서 선언한 repository를 조합하여 transaction을 생성하는 transaction repository를 새롭게 작성합니다.
class MyItemTxRepository(myItemRepository: MyItemRepository, myLogRepository: MyLogRepository)(implicit ex: ExecutionContext) extends SlickH2Config {
def updateStringTx(id: Int, myString: String): Future[MyItem] = {
db.run {
(for {
updateCount <- myItemRepository.updateQuery(id, myString)
_ <- if (updateCount < 1) DBIO.failed(new Exception("update failed")) else DBIO.successful()
item <- myItemRepository.findByIdQuery(id)
_ <- myLogRepository.saveQuery(MyLog.from(item.copy(myString = myString)))
} yield item).transactionally
}
}
}
Transaction Repository를 작성하는 이유
Slick은 Spring-data-jpa와는 다르게 트랜잭션 선언이 AOP로 빠져있지 않고, 데이터베이스 작업을 수행하기 위해서 db 정보도 직접 불러와야 합니다.
Transaction repository를 만들지 않으면 이러한 정보가 하나의 Repository에 전부 담겨있게 되거나, Repository 레이어의 정보가 Service 레이어까지 올라가야 합니다. Spring 같은 경우에는 이를 AOP로 따로 작성하여서 실제 코드 작성자는 Repository 레이어의 정보를 작성하지 않고도 알아서 추가해주는 식으로 동작하므로 레이어 간의 침투를 막을 수 있지만, Slick은 함수형 라이브러리답게 의존성이나 side effect가 모두 노출되므로 spring을 사용하던 것처럼 동일하게 동작시키려 하면 Service 레이어와 Repository 레이어가 하나로 합쳐지게 됩니다.
Transaction Repository를 만들어 사용하면 아래와 같은 장점이 있습니다.
- Repository layer는 Service layer와 여전히 분리될 수 있습니다
- Service 레이어의 테스트를 작성하기가 간편합니다. 기존 테스트처럼 Transaction layer를 mocking 하면 됩니다.
- Transaction query가 제대로 동작하는지 확인이 필요하면 repository 테스트만 작성하면 됩니다
- module 간의 분리롤 domain 모듈과 application 모듈이 분리되어 있을 때도 모듈끼리 서로 침범하지 않을 수 있습니다.
이렇게 Slick 트랜잭션 사용법을 알아보았는데, 모든 동작이 코드 형태로 드러나있어 코드를 보면 이해하기 쉽지만 막상 내가 직접 짜기는 쉽지 않습니다. Slick 트랜잭션 제약사항도 알아야하고, controller-service-repository 레이어로 구분된 어플리케이션일경우 적용하기도 쉽지 않습니다. 이러한 단점에도 불구하고 slick을 사용한다면 scala 코드 스타일에 맞춰서 sql 쿼리를 작성할 수 있어 이해하기도 쉽고 매우 유용합니다. 어떤 라이브러리던 장점/단점이 모두 존재하므로 이를 잘 알고 사용하면 되겠습니다