데이터베이스 트랜잭션
- 데이터베이스 관리 시스템에서 상호작용 단위
- 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) {
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) {
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();
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;
- 더티 리드
- 반복 불가한 조회
- 한 트랜잭션 내에서 같은 쿼리가 두 번 실행될 때 다른 결과가 나오는 상태
- 같은 데이터가 변했을 가능성이 있음
- 팬텀리드
- 같은 쿼리가 두 번 실행될 때 다른 결과가 나오는 상태
- 다른 데이터에 접근할 가능성 있음