[Spring DB] 트랜잭션 적용

DaeHoon·2022년 7월 15일
0
post-custom-banner

3-10. 트랜잭션 - 적용1

  • 실제 애플리케이션에서 DB 트랜잭션을 사용해서 계좌이체 같이 원자성이 중요한 비즈니스 로직을 어떻게 구현하는지 알아보자.
    먼저 트랜잭션 없이 단순하게 계좌이체 비즈니스 로직만 구현해보자.

MemberServiceV1

package hello.jbdc.service

import hello.jbdc.domain.Member
import hello.jbdc.repository.MemberRepositoryV1

class MemberServiceV1(
  private val memberRepository: MemberRepositoryV1
) {

  fun accountTransfer(fromId: String, toId: String, money: Int){
    val fromMember = memberRepository.findById(fromId)
    val toMember = memberRepository.findById(toId)

    memberRepository.update(fromId, fromMember.money  - money);
    validation(toMember);
    memberRepository.update(toId, toMember.money + money);
  }

  private fun validation(toMember: Member){
    if (toMember.memberId.equals("ex")){
      throw IllegalStateException("이체 중 예외 발생")
    }
  }
}

MemberServiceV1Test

package hello.jbdc.service

import hello.jbdc.connection.ConnectionConst
import hello.jbdc.domain.Member
import hello.jbdc.repository.MemberRepositoryV1
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.jdbc.datasource.DriverManagerDataSource
import java.sql.SQLException


internal class MemberServiceV1Test{

  val MEMBER_A = "memberA"
  val MEMBER_B = "memberB"
  val MEMBER_EX = "ex"

  lateinit var memberRepository: MemberRepositoryV1
  lateinit var  memberService: MemberServiceV1

  @BeforeEach
  fun before(){
    val dataSource = DriverManagerDataSource(ConnectionConst.URL,
      ConnectionConst.USERNAME, ConnectionConst.PASSWORD)

    memberRepository = MemberRepositoryV1(dataSource)
    memberService = MemberServiceV1(memberRepository)
  }

  @AfterEach
  fun after(){
    memberRepository.delete(MEMBER_A);
    memberRepository.delete(MEMBER_B);
    memberRepository.delete(MEMBER_EX);
  }

  @Test
  @DisplayName("정상 이체")
  @Throws(SQLException::class)
  fun accountTransfer() {
    //given
    val memberA = Member(MEMBER_A, 10000)
    val memberB = Member(MEMBER_B, 10000)
    memberRepository.save(memberA)
    memberRepository.save(memberB)
    //when
    memberService.accountTransfer(memberA.memberId,
      memberB.memberId, 2000)
    //then
    val findMemberA: Member = memberRepository.findById(memberA.memberId)
    val findMemberB: Member = memberRepository.findById(memberB.memberId)
    assertThat(findMemberA.money).isEqualTo(8000)
    assertThat(findMemberB.money).isEqualTo(12000)
  }

  @Test
  @DisplayName("이체 중 예외 발생")
  @Throws(SQLException::class)
  fun accountTransferEx(){
    //given
    val memberA = Member(MEMBER_A, 10000)
    val memberEx = Member(MEMBER_EX, 10000)

    // when

    assertThatThrownBy {
      memberService.accountTransfer(memberA.memberId, memberEx.memberId,
        2000)
    }
      .isInstanceOf(IllegalStateException::class.java)

    // then

    val findMemberA = memberRepository.findById(memberA.memberId)
    val findMemberEx = memberRepository.findById(memberEx.memberId)

    assertThat(findMemberA.money).isEqualTo(8000)
    assertThat(findMemberEx.money).isEqualTo(10000)

  }
}
  • 테스트 시나리오는 memberA, memberB 둘 다 10,000원씩 가지고 있고, A가 B에게 2,000원을 계좌이체 한다.
  • 이 테스트 코드는 트랜잭션이 적용되지 않아 예외 발생 시 문제가 발생한다.
  • 이체중 예외가 발생하게 되면 memberA 의 금액은 10000원 8000원으로 2000원 감소한다. 그런데 memberB 의 돈은 그대로 10000원으로 남아있다. 결과적으로 memberA 의 돈만 2000원 감소한 것이다!

3-10. 트랜잭션 - 적용2

  • 트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 한다. 비즈니스 로직이 잘못되면 해당 비즈니스 로직으로 인해 문제가 되는 부분을 함께 롤백해야 하기 때문이다.
  • 그런데 트랜잭션을 시작하려면 커넥션이 필요하다. 결국 서비스 계층에서 커넥션을 만들고, 트랜잭션 커밋 이후에 커넥션을 종료해야 한다.
  • 애플리케이션에서 DB 트랜잭션을 사용하려면 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야한다. 그래야 같은 세션을 사용할 수 있다.

  • 애플리케이션에서 같은 커넥션을 유지시키기 위해 커넥션을 파라미터로 전달한다. 리포지토리가 파라미터를 통해 같은 커넥션을 유지할 수 있도록 파라미터를 추가하자.

MemberRepositoryV2

package hello.jbdc.repository

import hello.jbdc.Log
import hello.jbdc.domain.Member
import org.springframework.jdbc.support.JdbcUtils
import java.sql.*
import javax.sql.DataSource
import kotlin.jvm.Throws


class MemberRepositoryV2(
  dataSource: DataSource,
):Log {
   private val dataSource: DataSource
  init {
    this.dataSource = dataSource
  }

  fun save(member: Member): Member{
    val sql: String = "insert into member(member_id, money) values (?, ?)"

    lateinit var con: Connection
    lateinit var pstmt: PreparedStatement

    try{
      con = getConnection()
      pstmt = con.prepareStatement(sql)
      pstmt.setString(1, member.memberId)
      pstmt.setInt(2, member.money)
      pstmt.executeUpdate()
      return member
    } catch(e: SQLException){
        logger.error("db error", e)
        e.printStackTrace()
        throw e
    } finally {
      close(con, pstmt, null);
    }

  }

  @Throws(SQLException::class)
  fun findById(memberId: String): Member{
    val sql = "select * from member where member_id = ?"

    lateinit var con: Connection
    lateinit var pstmt: PreparedStatement
    lateinit var rs: ResultSet

    try{
      con = getConnection()
      pstmt = con.prepareStatement(sql)
      pstmt.setString(1, memberId)

      rs = pstmt.executeQuery()

      if (rs.next()){
        val member = Member(
          memberId = rs.getString("member_id"),
          money = rs.getInt("money")
        )

        return member
      }else{
        throw NoSuchElementException("member not found memberId=" + memberId)
      }
    } catch (e: SQLException){
        logger.error("db error", e)
        throw e
    } finally {
      close(con, pstmt, null)
    }
  }

  @Throws(SQLException::class)
  fun findById(con: Connection, memberId: String): Member{
    val sql = "select * from member where member_id = ?"

    lateinit var pstmt: PreparedStatement
    lateinit var rs: ResultSet

    try{

      pstmt = con.prepareStatement(sql)
      pstmt.setString(1, memberId)

      rs = pstmt.executeQuery()

      if (rs.next()){
        val member = Member(
          memberId = rs.getString("member_id"),
          money = rs.getInt("money")
        )

        return member
      }else{
        throw NoSuchElementException("member not found memberId=" + memberId)
      }
    } catch (e: SQLException){
      logger.error("db error", e)
      throw e
    } finally {
      JdbcUtils.closeResultSet(rs);
      JdbcUtils.closeStatement(pstmt);
    }
  }

  @Throws(SQLException::class)
  fun update(memberId: String, money: Int){
    val sql = "update member set money=? where member_id=?"

    lateinit var con: Connection
    lateinit var pstmt: PreparedStatement

    try {
      con = getConnection();
      pstmt = con.prepareStatement(sql);
      pstmt.setInt(1, money);
      pstmt.setString(2, memberId);

      val resultSize = pstmt.executeUpdate()
      logger.info("resultSize={}", resultSize)
    } catch (e: SQLException){
        logger.error("db error", e)
        throw e
    } finally {
      close(con, pstmt, null)
    }
  }

  @Throws(SQLException::class)
  fun update(con: Connection, memberId: String, money: Int){
    val sql = "update member set money=? where member_id=?"
    lateinit var pstmt: PreparedStatement

    try {
      pstmt = con.prepareStatement(sql);
      pstmt.setInt(1, money);
      pstmt.setString(2, memberId);

      val resultSize = pstmt.executeUpdate()
      logger.info("resultSize={}", resultSize)
    } catch (e: SQLException){
      logger.error("db error", e)
      throw e
    } finally {
      JdbcUtils.closeStatement(pstmt);
    }
  }
  @Throws(SQLException::class)
  fun delete(memberId: String){
    val sql = "delete from member where member_id=?"

    lateinit var con: Connection
    lateinit var pstmt: PreparedStatement

    try {
      con = getConnection();
      pstmt = con.prepareStatement(sql);
      pstmt.setString(1, memberId);

      pstmt.executeUpdate()

    } catch (e: SQLException){
        logger.error("db error", e)
        throw e
    } finally {
      close(con, pstmt, null);
    }
  }


  private fun getConnection(): Connection {
    val con = dataSource.connection
    logger.info("get connection={}, class={}", con, con.javaClass)
    return con
  }

  private fun close(con: Connection?, stmt: Statement?, rs: ResultSet?) {
    JdbcUtils.closeResultSet(rs);
    JdbcUtils.closeStatement(stmt);
    JdbcUtils.closeConnection(con);
  }
}
  • MemberRepositoryV2 는 기존 코드와 같고 커넥션 유지가 필요한 다음 두 메서드가 추가되었다. 참고로 다음 두 메서드는 계좌이체 서비스 로직에서 호출하는 메서드이다.
    • findById(Connection con, String memberId)
    • update(Connection con, String memberId, int money)

주의

  • 커넥션 유지가 필요한 두 메서드는 파라미터로 넘어온 커넥션을 사용해야 한다. con = getConnection() 코드를 지우자.
  • 커넥션 유지가 필요한 두 메서드는 리포지토리에서 커넥션을 닫으면 안된다. 커넥션을 전달 받은 리포지토리 뿐만 아니라 이후에도 커넥션을 계속 이어서 사용하기 때문이다. 이후 서비스 로직이 끝날 때 트랜잭션을 종료하고 닫아야 한다.

MemberServiceV2

package hello.jbdc.service

import hello.jbdc.Log
import hello.jbdc.domain.Member
import hello.jbdc.repository.MemberRepositoryV2
import javax.sql.DataSource
import java.sql.Connection
import java.sql.SQLException
import kotlin.jvm.Throws

class MemberServiceV2(
  private val dataSource: DataSource,
  private val memberRepository: MemberRepositoryV2,
) :Log{

  fun accountTransfer(fromId: String, toId: String, money: Int){
    val con = dataSource.connection
    try {
      con.autoCommit = false
      bizLogic(con, fromId, toId, money)
      con.commit()
    }catch (e: Exception){
      con.rollback()
      throw IllegalStateException(e)
    }finally {
      release(con)
    }
  }

  private fun validation(toMember: Member){
    if (toMember.memberId.equals("ex")){
      throw IllegalStateException("이체 중 예외 발생")
    }
  }

  private fun release(con: Connection){
      try{
        con.autoCommit = true
        con.close()
      } catch (e: Exception){
        logger.info("error", e)
    }
  }

  @Throws(SQLException::class)
  private fun bizLogic(con: Connection, fromId: String, toId: String, money: Int){
    val fromMember = memberRepository.findById(con, fromId)
    val toMember = memberRepository.findById(con, toId)

    memberRepository.update(con, fromId, fromMember.money - money)
    validation(toMember)
    memberRepository.update(con, toId, fromMember.money + money)
  }
}
  • con.autoCommit = false : 트랜잭션을 수동으로 바꿈
  • bizLogic(con, fromId, toId, money): 트랜잭션이 시작된 커넥션을 전달하면서 비즈니스 로직을 수행한다. 이렇게 분리한 이유는 트랜잭션을 관리하는 로직과 실제 비즈니스 로직을 구분하기 위함이다.
  • release(con): finally {..} 를 사용해서 커넥션을 모두 사용하고 나면 안전하게 종료한다. 그런데 커넥션 풀을 사용하면 con.close() 를 호출 했을 때 커넥션이 종료되는 것이 아니라 풀에 반납된다. 현재 수동 커밋 모드로 동작하기 때문에 풀에 돌려주기 전에 기본 값인 자동 커밋 모드로 변경하는 것이 안전하다.

MemberServiceV2Test

package hello.jbdc.service

import hello.jbdc.connection.ConnectionConst
import hello.jbdc.domain.Member
import hello.jbdc.repository.MemberRepositoryV2
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.jdbc.datasource.DriverManagerDataSource
import java.sql.SQLException


internal class MemberServiceV2Test{

  val MEMBER_A = "memberA"
  val MEMBER_B = "memberB"
  val MEMBER_EX = "ex"

  lateinit var memberRepository: MemberRepositoryV2
  lateinit var  memberService: MemberServiceV2

  @BeforeEach
  fun before(){
    val dataSource = DriverManagerDataSource(ConnectionConst.URL,
      ConnectionConst.USERNAME, ConnectionConst.PASSWORD)

    memberRepository = MemberRepositoryV2(dataSource)
    memberService = MemberServiceV2(dataSource, memberRepository)
  }

  @AfterEach
  @Throws(SQLException::class)
  fun after(){
    memberRepository.delete(MEMBER_A);
    memberRepository.delete(MEMBER_B);
    memberRepository.delete(MEMBER_EX);
  }

  @Test
  @DisplayName("정상 이체")
  @Throws(SQLException::class)
  fun accountTransfer() {
    //given
    val memberA = Member(MEMBER_A, 10000)
    val memberB = Member(MEMBER_B, 10000)
    memberRepository.save(memberA)
    memberRepository.save(memberB)
    //when
    memberService.accountTransfer(memberA.memberId,
      memberB.memberId, 2000)
//    //then
    val findMemberA: Member = memberRepository.findById(memberA.memberId)
    val findMemberB: Member = memberRepository.findById(memberB.memberId)
    assertThat(findMemberA.money).isEqualTo(8000)
    assertThat(findMemberB.money).isEqualTo(12000)
  }

  @Test
  @DisplayName("이체 중 예외 발생")
  fun accountTransferEx(){
    //given
    val memberA = Member(MEMBER_A, 10000)
    val memberEx = Member(MEMBER_EX, 10000)
    memberRepository.save(memberA)
    memberRepository.save(memberEx)
    // when

    assertThatThrownBy {
      memberService.accountTransfer(memberA.memberId, memberEx.memberId,
        2000)
    }
      .isInstanceOf(IllegalStateException::class.java)

    // then
    val findMemberA = memberRepository.findById(memberA.memberId)
    val findMemberEx = memberRepository.findById(memberEx.memberId)

    assertThat(findMemberA.money).isEqualTo(10000)
    assertThat(findMemberEx.money).isEqualTo(10000)

  }
}

이체중 예외 발생 - accountTransferEx()

  • 1) 다음 데이터를 저장해서 테스트를 준비한다.
    • memberA 10000원
    • memberEx 10000원 계좌이체 로직을 실행한다.
  • 2) 계좌이체 로직을 실행한다.
    • memberService.accountTransfer() 를 실행한다
    • 커넥션을 생성하고 트랜잭션을 시작한다.
    • memberA memberEx 로 2000원 계좌이체 한다.memberA 의 금액이 2000원 감소하지만, memberEx 회원의 ID는 ex 이므로 중간에 예외가 발생한다.
    • 예외가 발생했으므로 트랜잭션을 롤백한다.
  • 3) 계좌이체는 실패했다. 롤백을 수행해서 memberA 의 돈이 기존 10000원으로 복구되었다.
    • memberA 10000원 - 트랜잭션 롤백으로 복구된다.
    • memberB 10000원 - 중간에 실패로 로직이 수행되지 않았다. 따라서 그대로 10000원으로 남아있게 된다.
  • 트랜잭션 덕분에 계좌이체가 실패할 때 롤백을 수행해서 모든 데이터를 정상적으로 초기화 할 수 있게 되었다. 결과적으로 계좌이체를 수행하기 직전으로 돌아가게 된다.

남은 문제

애플리케이션에서 DB 트랜잭션을 적용하려면 서비스 계층이 매우 지저분해지고, 생각보다 매우 복잡한 코드를 요구한다. 추가로 커넥션을 유지하도록 코드를 변경하는 것도 쉬운 일은 아니다. 다음 시간에는 스프링을 사용해서 이런 문제들을 하나씩 해결해보자.

profile
평범한 백엔드 개발자
post-custom-banner

0개의 댓글