SpringBoot #2.4 JDBC - 트랜잭션

텐저린티·2023년 6월 30일
0

데브코스

목록 보기
19/41
post-thumbnail

데이터베이스 트랜잭션

  • 데이터베이스 관리 시스템에서 상호작용 단위
  • ACID 특성
    • 원자성 (Atomic) : 트랜잭션이 모두 성공하거나, 모두 반영되지 않음을 보장해야 함
    • 일관성 (Consistency) : 트랜잭션은 데이터베이스 제약, 규칙을 준수해야함
    • 독립성 (Isolation) : 트랜잭션끼리 서로 영향을 주어선 아니됨
    • 지속성 (Duration) : 한 번 커밋된 내용은 영구 지속돼야 함

예제

  • customerId, email 은 unique 속성
  • updateNameStatement : customerId를 가지고 이름 변경
  • updateEmailStatement : customerId를 가지고 이메일 변경
  • 이미 존재하는 email로 변경하려는 상황임
  • 두 개의 statement를 connection.setAutoCommit(false)로 하나의 트랜잭션으로 묶음
  • updateEmailStatement 쿼리 실행 시 예외 발생
  • 트랜잭션 내 모든 실행들이 모두 rollback 됨
  • 트랜잭션 실행 전의 상태로 복구된다는 의미.
public void transactionTest(Customer customer) {
    String updateNameSql = "UPDATE customers SET name = ? WHERE customer_id = UUID_TO_BIN(?)";
    String updateEmailSql = "UPDATE customers SET email = ? WHERE customer_id = UUID_TO_BIN(?)";

    Connection connection = null;
    try {
        connection = DriverManager.getConnection("jdbc:mysql://localhost/order_mgmt", "root", "root1234!");
        connection.setAutoCommit(false);
        try (
                var updateNameStatement = connection.prepareStatement(updateNameSql);
                var updateEmailStatement = connection.prepareStatement(updateEmailSql);
        ) {
            updateNameStatement.setString(1, customer.getName());
            updateNameStatement.setBytes(2, customer.getCustomerId().toString().getBytes());
            updateNameStatement.executeUpdate();

            updateEmailStatement.setString(1, customer.getEmail());
            updateEmailStatement.setBytes(2, customer.getCustomerId().toString().getBytes());
            updateEmailStatement.executeUpdate();
            connection.setAutoCommit(true);
        }
    } catch (SQLException exception) {
        if (connection != null) {
            try {
                connection.rollback();
                connection.close();
            } catch (SQLException throwable) {
                logger.error("Got error while closing connection", throwable);
                throw new RuntimeException(exception);
            }
        }
        logger.error("Got error while closing connection", exception);
        throw new RuntimeException(exception);
    }
}

public static void main(String[] args) {
    var customerRepository = new JdbcCustomerRepository();

    customerRepository.transactionTest(
            new Customer(
                    UUID.fromString("e930ace2-bd28-4e88-9e52-838e7a0e1916"),
                    "update-user", "new-user2@gmail.com",
                    LocalDateTime.now()
            )
    );
}

트랜잭션 관리

TransactionManager 방법

  • CustomerNamedJdbcRepository
private final PlatformTransactionManager transactionManager;

public void testTransaction(Customer customer) {
    // 트랜잭션 설정
    var transaction = transactionManager.getTransaction(new DefaultTransactionDefinition());
    try {
        jdbcTemplate.update("UPDATE customers SET name = :name WHERE customer_id = UUID_TO_BIN(:customerId)", toParamMap(customer));
        jdbcTemplate.update("UPDATE customers SET email = :email WHERE customer_id = UUID_TO_BIN(:customerId)", toParamMap(customer));
        transactionManager.commit(transaction);
    } catch (DataAccessException e) {
        logger.error("Got error", e);
        transactionManager.rollback(transaction);
    }
}
  • 테스트 클래스
// 설정 파일에 빈 등록
@Bean
public PlatformTransactionManager platformTransactionManager(DataSource dataSource) {
    return new DataSourceTransactionManager(dataSource);
}

@Test
@DisplayName("트랜잭션 테스트")
void testTransaction() {
    // 예전에 만든거
    var prevOne = customerJdbcRepository.findById(newCustomer.getCustomerId());
    assertThat(prevOne.isEmpty(), is(false));

    // 새로 만든거
    var newOne = new Customer(UUID.randomUUID(), "a", "a@gmail.com", LocalDateTime.now());
    var insertedNewOne = customerJdbcRepository.insert(newOne);
    customerJdbcRepository.testTransaction(
            new Customer(
                insertedNewOne.getCustomerId(),
                "b",
                prevOne.get().getEmail(),
                newOne.getCreateAt()
            )
    );
    // 새로 만든거랑 같은 녀석이어야 함
    var maybeNewOne = customerJdbcRepository.findById(insertedNewOne.getCustomerId());
    // 같은 녀석인지 테스트
    assertThat(maybeNewOne.isEmpty(), is(false));
    assertThat(maybeNewOne.get(), samePropertyValuesAs(newOne));
}

TransactionTemplate 방법

  • CustomerNamedJdbcRepository
private final TransactionTemplate transactionTemplate;

public void testTransaction(Customer customer) {
    // 반환받을 값이 있으면 
    // new TransactionCallback<>()
    // 반환받을 값이 없으면 이거
    transactionTemplate.execute(new TransactionCallbackWithoutResult() { 
        @Override
        protected void doInTransactionWithoutResult(TransactionStatus status) {
            jdbcTemplate.update("UPDATE customers SET name = :name WHERE customer_id = UUID_TO_BIN(:customerId)", toParamMap(customer));
            jdbcTemplate.update("UPDATE customers SET email = :email WHERE customer_id = UUID_TO_BIN(:customerId)", toParamMap(customer));
        }
    });
}
  • 테스트 클래스
// 빈 등록
@Bean
public TransactionTemplate transactionTemplate() {

@Test
@DisplayName("트랜잭션 테스트")
void testTransaction() {
    // 예전에 만든거
    var prevOne = customerJdbcRepository.findById(newCustomer.getCustomerId());
    assertThat(prevOne.isEmpty(), is(false));

    // 새로 만든거
    var newOne = new Customer(UUID.randomUUID(), "a", "a@gmail.com", LocalDateTime.now());
    var insertedNewOne = customerJdbcRepository.insert(newOne);
    try {
        customerJdbcRepository.testTransaction(
                new Customer(
                        insertedNewOne.getCustomerId(),
                        "b",
                        prevOne.get().getEmail(),
                        newOne.getCreateAt()
                )
        );
    } catch (DataAccessException e) {
        // 롤백되는 걸 확인하기 위한 try-catch
        // 트랜잭션이 실패했기 때문에 롤백되면서 여기에 오는 거고
        // 롤백되었기 때문에 아래 있는 assert 문은 성공이 되는거임
        logger.error("Got error when testing transaction", e);
    }
    // 새로 만든거랑 같은 녀석이어야 함
    var maybeNewOne = customerJdbcRepository.findById(insertedNewOne.getCustomerId());
    // 같은 녀석인지 테스트
    assertThat(maybeNewOne.isEmpty(), is(false));
    assertThat(maybeNewOne.get(), samePropertyValuesAs(newOne));
}

@Transactional 방법

  • 선언형 방법
  • 어노테이션을 사용하면 해당 클래스에 대해서 스프링 AOP가 프록시를 만들어줌
  • 그 프록시를 이용해서 스프링은 트랜잭션 설정과 트랜잭션 커밋, 롤백과 같이 중복되는 횡단 관심사를 처리해줌
  • 우리는 횡단관심사가 제거되었으니 전에 쓰던 Connection 설정 방법, TransactionManager 방법, TransactionTemplate 방법과는 다르게 커밋, 롤백, 예외처리, Connection 설정 등 중복되는 로직을 구현하지 않고 비즈니스 로직에만 집중 가능
  • CustomerService

interface CustomerService {
	void createCustomers(List<Customer> customers);
}

public class CustomerServiceImpl implements CustomerService {

	private final CustomerRepository customerRepository;
	
	public CustomerServiceImpl(CustomerRepository customerRepository) {
	    this.customerRepository = customerRepository;
	}
	
	@Override
	@Transactional
	public void createCustomers(List<Customer> customers) {
	    customers.forEach(customerRepository::insert);
	}
}
  • 테스트 클래스
@SpringJUnitConfig
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CustomerServiceTest {

    private static final Logger logger = LoggerFactory.getLogger(CustomerServiceTest.class);

    @Configuration
    @EnableTransactionManagement
    static class Config {
        @Bean
        public DataSource dataSource() {
            var dataSource = DataSourceBuilder.create()
                    .url("jdbc:mysql://localhost:2215/test-order_mgmt")
                    .username("test")
                    .password("test1234!")
                    .type(HikariDataSource.class)
                    .build();
            // HikariCP가 풀에 커넥션(스레드)를 100개 만들어두고 시작한다는 것을 확인하기 위한 코드
            // Customer show status '%Threads%' 쿼리랑 함께 써서 확인할 수 있다.
            dataSource.setMaximumPoolSize(1000);
            dataSource.setMinimumIdle(100);
            return dataSource;
        }

        @Bean
        public JdbcTemplate jdbcTemplate(DataSource dataSource) {
            return new JdbcTemplate(dataSource);
        }

        @Bean
        public NamedParameterJdbcTemplate namedParameterJdbcTemplate(JdbcTemplate jdbcTemplate) {
            return new NamedParameterJdbcTemplate(jdbcTemplate);
        }

        @Bean
        public PlatformTransactionManager platformTransactionManager(DataSource dataSource) {
            return new DataSourceTransactionManager(dataSource);
        }

        @Bean
        public TransactionTemplate transactionTemplate(PlatformTransactionManager platformTransactionManager) {
            return new TransactionTemplate(platformTransactionManager);
        }

        @Bean
        public CustomerRepository customerRepository(NamedParameterJdbcTemplate namedParameterJdbcTemplate) {
            return new CustomerNamedJdbcRepository(namedParameterJdbcTemplate);
        }

        @Bean
        public CustomerService customerService(CustomerRepository customerRepository) {
            return new CustomerServiceImpl(customerRepository);
        }
    }

    static EmbeddedMysql embeddedMysql;

    @BeforeAll
    static void setup() {
        var mysqlConfig = aMysqldConfig(v8_0_11)
                .withCharset(UTF8)
                .withPort(2215)
                .withUser("test", "test1234!")
                .withTimeZone("Asia/Seoul")
                .build();
        embeddedMysql = anEmbeddedMysql(mysqlConfig)
                .addSchema("test-order_mgmt", classPathScript("schema.sql"))
                .start();
    }

    @AfterAll
    static void cleanup() {
        embeddedMysql.stop();
    }

    @AfterEach
    void dataCleanup() {
        customerRepository.deleteAll();
    }

    @Autowired
    CustomerService customerService;

    @Autowired
    CustomerRepository customerRepository;

    @Test
    @DisplayName("여러건 추가 테스트")
    void multiInsertTest() {
        var customers = List.of(
                new Customer(UUID.randomUUID(), "a", "a@naver.com", LocalDateTime.now()),
                new Customer(UUID.randomUUID(), "b", "b@naver.com", LocalDateTime.now())
        );

        customerService.createCustomers(customers);
        var allCustomersRetrieved = customerRepository.findAll();
        assertThat(allCustomersRetrieved.size(), is(2));
        assertThat(allCustomersRetrieved, containsInAnyOrder(samePropertyValuesAs(customers.get(0)), samePropertyValuesAs(customers.get(1))));
    }

    @Test
    @DisplayName("여러건 추가 실패 시 전체 트랜잭션이 롤백되어야 한다.")
    void multiRollbackTest() {
        var customers = List.of(
                new Customer(UUID.randomUUID(), "a", "c@naver.com", LocalDateTime.now()),
                new Customer(UUID.randomUUID(), "b", "c@naver.com", LocalDateTime.now())
        );

        try {
            customerService.createCustomers(customers);
        } catch (DataAccessException e) {

        }
        var allCustomersRetrieved = customerRepository.findAll();
        assertThat(allCustomersRetrieved.size(), is(0));
        assertThat(allCustomersRetrieved.isEmpty(), is(true));
        assertThat(allCustomersRetrieved, not(containsInAnyOrder(samePropertyValuesAs(customers.get(0)), samePropertyValuesAs(customers.get(1)))));
    }

}

트랜잭션 전파

  • 특정 트랜잭션 처리 내에서 다른 트랜잭션 처리가 발생하는거
  • @Transactional(propagation = …)
  • 종류
    • REQUIRED
      • 기본값
      • 트랜잭션 필요
      • 진행중인 트랜잭션 존재 → 트랜잭션 사용
      • 진행중인 트랜잭션 부재 → 새로운 트랜잭션 시작
    • MANDATORY
      • 호출 전 반드시 진행중인 트랜잭션 존재해야 함
    • REQUIRED_NEW
      • 항상 새로운 트랜잭션 시작
      • 진행중인 트랜잭션은 잠시 중단되고 새로 시작한 트랜잭션 종료 후 재개됨
    • SUPPORTS
      • 진행중인 트랜잭션이 있는 경우 해당 트랜잭션 사용
    • NOT_SUPPORTED
      • 트랜잭션 불필요
      • 진행중인 트랜잭션 존재 시 중단하고 다른 트랜잭션 종료 후 재개
    • NEVER
      • 진행중인 트랜잭션 있는 경우 예외
    • NESTED
      • 진행중인 트랜잭션 존재 시 중첩된 트랜잭션에서 실행
      • 중첩된 트랜잭션은 서로 독립적
      • 진행중인 트랜잭션 부재 시 REQUIRED와 동일 동작

트랜잭션 격리 (Transaction Isolation Level)

  • @Transactional(isolation = …)
SELECT @@SESSION.transaction_isolation;

  • 더티 리드
    • 커밋되지 않은 값도 조회 가능한 상태
  • 반복 불가한 조회
    • 한 트랜잭션 내에서 같은 쿼리가 두 번 실행될 때 다른 결과가 나오는 상태
    • 같은 데이터가 변했을 가능성이 있음
  • 팬텀리드
    • 같은 쿼리가 두 번 실행될 때 다른 결과가 나오는 상태
    • 다른 데이터에 접근할 가능성 있음
profile
개발하고 말테야

0개의 댓글

관련 채용 정보