코딩하는 오징어

Slick을 통한 트랜잭션 적용 본문

Framework/Slick

Slick을 통한 트랜잭션 적용

코딩하는 오징어 2020. 2. 23. 00:50
반응형

Slick에서 Transaction 적용 (slick version 3.3.1)


Slick을 설정함에 있어 여러가지가 있겠지만 개인적으로 Cake Pattern을 이용하여 Config를 구성해 나가는 것을 선호한다.
(참조 링크: Slick Cake Pattern Config)

 

  1. slick은 DBIOAction을 통해 쿼리를 실행한다. 보통은 type alias된 DBIO를 통해 사용한다.
  2. future를 DBIOAction으로 바꾸고 싶다면 DBIO.from(...)을 통해 간단하게 바꿀 수 있다.
// Import the Scala API from the profile
import profile.api._

...

(for {
  _ <- ...
  _ <- DBIO.from(future)
  _ <- ...
} yield (): Unit).transactionally

...
  • slick은 위와 같이 for comprehension을 통해 여러 DBIOAction을 쉽게 transaction으로 묶을 수 있다.


 개인적으로 선호하는 패턴을 적용하여 트랜잭션을 적용한 예제 코드를 통해 설명을 이어 나가겠다.(Slick 설정은 이 글에서 자세히 설명하지 않을 것이다.) 다음은 준비 코드이다.

* build.sbt


libraryDependencies ++= Seq(
  "mysql" % "mysql-connector-java" % "8.0.16",
  "com.typesafe.slick" %% "slick" % "3.3.2" exclude("com.typesafe", "config"),
  "org.slf4j" % "slf4j-api" % "1.6.4",
  "com.typesafe.slick" %% "slick-hikaricp" % "3.3.2" exclude("com.typesafe", "config"),
  "com.typesafe" % "config" % "1.3.3"
)

 

*application.conf

slick.db {
  profile = "slick.jdbc.MySQLProfile$"
  db {
    url = "jdbc:mysql://localhost:3306/tmp?useUnicode=true&characterEncoding=UTF-8&cacheServerConfiguration=true&zeroDateTimeBehavior=convertToNull&autoReconnect=false&useSSL=false"
    driver = com.mysql.cj.jdbc.Driver
    user = "user"
    password = "password"
    poolName = "transaction-test"
  }
}
trait BaseComponent {

  protected lazy val db: JdbcBackend#DatabaseDef = BaseComponent.db
  protected lazy val profile: JdbcProfile = BaseComponent.profile
}

object BaseComponent {

  private lazy val dbConfig: DatabaseConfig[JdbcProfile] = DatabaseConfig.forConfig[JdbcProfile](path = "slick.db", config = ConfigFactory.defaultApplication())
  private lazy val db: JdbcBackend#DatabaseDef = dbConfig.db
  private lazy val profile: JdbcProfile = dbConfig.profile
}
case class User(id: Option[Long], name: String, email: String)

trait UserComponent extends BaseComponent {

  import profile.api._

  class UserTable(tag: Tag) extends Table[User](tag, "users") {

    val id: Rep[Option[Long]] = column[Option[Long]]("id", O.AutoInc, O.PrimaryKey)
    val name: Rep[String] = column[String]("name")
    val email: Rep[String] = column[String]("email")

    override def * : ProvenShape[User] = (id, name, email <> (User.tupled, User.unapply)
  }

  protected val users = TableQuery[UserTable]
}
trait BaseRepository {

  this: BaseComponent => // Repository가 Component trait mixin을 강제하기 위함
  def ddl: profile.DDL
}
class UserRepository()(implicit ec: ExecutionContext) extends BaseRepository with UserComponent {

  import profile.api._

  def save(entity: User): Future[User] = {
    db.run(saveQuery(entity))
  }

  def saveQuery(entity: User): FixedSqlAction[User, NoStream, Effect.Write] = {
    users returning users.map(_.id) into ((e, id) => e.copy(id = id)) += entity
  }

  override def ddl: profile.DDL = users.schema
}

 지금까지 준비한 코드는 간단하다. users테이블의 정보를 User, UserComponent를 통해 mapping하였고 이를 이용하여 Repository layer를 준비하였다. Spring기반 어플리케이션을 개발할 때는 Service layer에서 @Transactional을 통해 손쉽게 메서드를 트랜잭션으로 묶어 트랜잭션 처리를 손쉽게 구현할 수 있었다. 하지만 scala로 개발을 시작하면서 (play or akka) & persistence 계층을 slick을 이용하였는데 트랜잭션 관련 처리를 어느 layer에서 해야하는지 고민이 많았다. slick을 통해 transaction처리를 하려면 import profile.api._ 구문이 필요한데 여기서 profile은 위의 준비된 코드를 보다시피 repository영역에서 필요한 구문이다. 개인적으로 layer간의 침범을 좋아하지않아 트랜잭션을 처리하기 위해 Service layer에 저 구문을 사용하고 싶지 않았다. 그렇다고 Repository layer에서 비즈니스 로직에 속하는 트랜잭션처리를 하고 싶지도 않았다. 그래서 TxService layer를 별도로 두어 이를 해결하였다. Spring기반 어플리케이션을 개발할 때도 master와 slave로 쿼리를 구분해서 보내는 등 트랜잭션 처리를 하드하게 처리 해야한다면 TxService layer를 별도로 두어 처리하였는데 여기서 영감을 얻었다. 그럼 다음 코드를 살펴보자.

User를 등록할 때 외부시스템에도 해당 유저를 우리 서비스의 유저로 등록한다고 알려야한다고 가정하자.

trait BaseTxService {

  // import profile.api._ 구문이 필요하기 때문에 profile을 정의한 BaseComponent가 필요하다.
  this: BaseComponent =>
}

// ExtenralClient는 외부 시스템과의 통신을 위한 클래스이다. 따로 코드를 준비하진 않았다.
class UserTxService(
  private val userRepository: UserRepository,
  private val externalClient: ExtenralClient) extends BaseTxService with UserComponent {

  import profile.api._

  def save(entity: User): Future[User] = {
    val tx = (for {
      user <- userRepository.saveQuery(entity) // DBIO.from(userRepository.save(entity))로도 가능
      _ <- DBIO.from(externalClient.send(user).map {
          case Success(_) => ():Unit
          case Failure(_) => throw new RuntimeException("외부 시스템 통신 실패")
        })
    } yield user).transactionally

    db.run(tx).recover { ... }
  }
}

 

 이제 UserService 에서 UserTxService를 통해 유저를 등록하면 된다. 비즈니스 로직은 UserService에 트랜잭션 관련 처리 로직은 UserTxService로 책임을 나누면 layer도 깔끔하게 분리되면서 어떤 layer에 종속적인 부분이 관련 없는 layer로 전파되는 것을 막을 수 있다. Spring기반 어플리케이션은 트랜잭션으로 묶는 과정이 너무 쉬워 자칫하면 트랜잭션을 남용하게 될 수도 있다. 트랜잭션은 비용이 비싼 연산이므로 신중하게 이용하는 것이 개인적으로 좋다고 생각한다.

 

추가적으로 DBIOAction들을 트랜잭션으로 묶을 때 DBIO.failed로 실패 처리를 할 수 없는 경우 exception을 throw 해주면된다. 다만 db.run(tx).recover { ... }를 통해 에러로그를 남기는 등 핸들링을 해주어야한다.
반응형
Comments