Slick Transaction

테크·2024년 4월 28일

Slick

목록 보기
2/2

Transaction

Slick에서 Transaction을 사용하고 싶다면 DBIOActrion 타입에 transactionally 함수를 사용하면 됩니다.
사용법은 slick document를 보면 간단히 알 수 있습니다.
Slick의 트랜잭션에 대해 알기 전에 쿼리를 여러건 실행하고 싶을 때의 Slick 사용법을 먼저 알아보겠습니다.

Multiple query

Slick의 Query 타입들은 모두 DBIOAction을 상속받습니다.
DBIOAction 타입을 살펴보면 내부에 map과 flatMap, zip 등 다양한 함수들을 구현해 둔 것을 알 수 있습니다.
flatMap과 map이 구현되어 있으므로 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의 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에 조합가능한 함수로 Jdbc에서의 정의는 아래와 같습니다.

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]]
    )

코드를 보면 알 수 있는 사실은 transaction을 시작하고, 설정된 DB 작업을 처리하고, 어떤 에러도 없으면 현재 ExecutionContext에서 commit을 수행하거나 rollback을 수행한다는 것입니다.

slick document에도 transactionally에 대한 설명이 있는데 중요한 부분들만 적으면 아래와 같습니다.

DBIOAction은 수많은 작은 작업들이 조합되어 만들어지고, DB 작업 이외의 작업도 연결이 가능하며, 내부적으로 커넥션 풀을 사용하여 필요치 않은 상황에서는 커넥션을 자율적으로 해제하여 효율적으로 사용한다

DB 작업들로만 이루어진 DBIOAction은 효율적으로 작업을 수행하지만, 하나의 세션만 사용하는 부작용이 있다

transactionally를 사용하면 하나의 세션만 사용하게 되고 원자적으로 동작한다(Commit or Rollback)

주의사항이 하나 있는데, Spring-data-jpa를 사용하던 분들은 @transactionally 어노테이션을 사용해서 새로운 트랜잭션을 시작하거나 참가하거나 등 다양한 방법으로 활용하지만, Slick은 가장 바깥의 transactionally 하나만 적용됩니다. 즉, 내부적으로 여러 transactionally가 중첩되어 있다면, 가장 바깥의 트랜잭션만 작동해서 내부의 transactionally는 기존의 트랜잭션에 참가한다고 여기면 됩니다.

만약 임의로 자체적인 롤백이 필요하다면 Spring에서 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 작업들과 같이 사용하고 싶다면?

현재 데이터베이스와 다른 데이터베이스를 여러개 조합해서 사용중일 수 있습니다. 예를들면 MongoDB에는 로그를 기록하고, MySQL에는 실제 데이터를 기록할 수도 있고요. 이런 상황에서 둘 모두 하나의 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, Some("myschema"), "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, Some("logschema"), "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을 생성하는 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은 함수형 라이브러리답게 모든게 투명하게 보이므로 동일하게 동작시키려 하면 Service 레이어와 Repository 레이어가 구분되지 않게 됩니다.

이렇게 Transaction Repository를 만들 때의 장점은 아래와 같습니다

  1. Repository layer는 Service layer와 여전히 분리될 수 있다.
  2. Service 레이어의 테스트를 작성하기 간편하다. 기존 테스트처럼 Transaction layer를 mocking 하기만 하면 된다.
  3. Transaction query가 제대로 동작하는지 확인이 필요하면 repository 테스트를 작성하면 된다
  4. module간의 분리로 domain 모듈과 application 모듈이 분리되어 있을 때도 모듈끼리 서로 침범하지 않을 수 있다.

이렇게 Slick의 트랜잭션 사용법을 알아보았는데, 모든게 투명하게 드러나있어 코드를 이해하기 쉽지만 어떤 작업도 대신해주지 않아서 막상 처음 손대면 오히려 코드를 짜기 어렵습니다. Slick 트랜잭션 제약도 알아야하고, Controller-Service-Repository 레이어로 구분된 어플리케이션일경우 적용하기도 쉽지 않습니다. 이러한 단점도 있지만 Scala 코드스타일과 맞춰서 코드를 작성이 가능하다는 장점도 있어서 장점/단점을 잘 알고 사용하면 되겠습니다.

profile
공부하는 개발자

0개의 댓글