From d26689d8c075672ef1b69ddadc2ee71c2f88bb28 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 13 Oct 2023 11:39:39 +0900 Subject: [PATCH] =?UTF-8?q?[JDBC=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=ED=98=84=ED=95=98=EA=B8=B0=20-=204?= =?UTF-8?q?=EB=8B=A8=EA=B3=84]=20=EC=97=90=EB=8B=A8(=EA=B9=80=EC=84=9D?= =?UTF-8?q?=ED=98=B8)=20=EB=AF=B8=EC=85=98=20=EC=A0=9C=EC=B6=9C=ED=95=A9?= =?UTF-8?q?=EB=8B=88=EB=8B=A4.=20(#556)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 기능구현요구사항 작성 * refactor: 트랜잭션 경계 설정 * test: 격리 레벨 테스트 추가 * test: 트랜잭션 전파 테스트 추가 * docs: 기능 요구 사항 작성 * refactor: Transaction synchronization 적용 * refactor: 트랜잭션 서비스 추상화 * refactor: 쓰레드 풀 사용시 안전하게 쓰레드 로컬을 사용하도록 변경 * refactor: 트랜잭션 로직을 TransactioTemplate으로 분리 * refactor: 사용자 입력값 예외 검증 메세지 변경 및 예외 종류 변경 * refactor: DataSource를 외부에서 주입받도록 변경 * refactor: rollback 메서드를 1 depth로 변경 * refactor: findById에서도 트랜잭션이 적용되도록 변경 * refactor: TransactionTemplate 관련 메소드 springframework 패키지로 이동 * test: 학습테스트 Isolation Level 범위 최소화 * test: 학습테스트 stage2 주석추가 --- README.md | 2 + .../main/java/com/techcourse/dao/UserDao.java | 7 -- .../com/techcourse/dao/UserHistoryDao.java | 16 ---- .../techcourse/service/AppUserService.java | 37 +++++++++ .../com/techcourse/service/TxUserService.java | 30 +++++++ .../com/techcourse/service/UserService.java | 79 +------------------ .../service/MockUserHistoryDao.java | 4 +- .../techcourse/service/UserServiceTest.java | 8 +- .../jdbc/core/JdbcTemplate.java | 23 +----- .../jdbc/datasource/DataSourceUtils.java | 8 +- .../transaction/TransactionException.java | 8 ++ .../support/TransactionCommandExecutor.java | 7 ++ .../support/TransactionQueryExecutor.java | 7 ++ .../TransactionSynchronizationManager.java | 32 ++++++-- .../support/TransactionTemplate.java | 76 ++++++++++++++++++ .../java/transaction/stage1/Stage1Test.java | 44 +++++------ .../transaction/stage2/FirstUserService.java | 17 ++-- .../java/transaction/stage2/Stage2Test.java | 58 ++++++++------ 18 files changed, 277 insertions(+), 186 deletions(-) create mode 100644 app/src/main/java/com/techcourse/service/AppUserService.java create mode 100644 app/src/main/java/com/techcourse/service/TxUserService.java create mode 100644 jdbc/src/main/java/org/springframework/transaction/TransactionException.java create mode 100644 jdbc/src/main/java/org/springframework/transaction/support/TransactionCommandExecutor.java create mode 100644 jdbc/src/main/java/org/springframework/transaction/support/TransactionQueryExecutor.java create mode 100644 jdbc/src/main/java/org/springframework/transaction/support/TransactionTemplate.java diff --git a/README.md b/README.md index aa37b938f5..2c56315607 100644 --- a/README.md +++ b/README.md @@ -8,3 +8,5 @@ - [x] update() - [x] JdbcTemplate 으로 중복 제거 - [x] 트랜잭션 경계 설정하기 +- [x] Transaction synchronization 적용하기 +- [x] 트랜잭션 서비스 추상화하기 diff --git a/app/src/main/java/com/techcourse/dao/UserDao.java b/app/src/main/java/com/techcourse/dao/UserDao.java index 29089dce2d..4fdfb28d91 100644 --- a/app/src/main/java/com/techcourse/dao/UserDao.java +++ b/app/src/main/java/com/techcourse/dao/UserDao.java @@ -4,7 +4,6 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; -import java.sql.Connection; import java.util.List; import java.util.Optional; @@ -33,12 +32,6 @@ public void update(final User user) { jdbcTemplate.execute(sql, user.getAccount(), user.getPassword(), user.getEmail(), user.getId()); } - public void update(final Connection connection, final User user) { - final var sql = "update users set (account, password, email) = (?, ?, ?) where id = ?"; - - jdbcTemplate.executeWithConnection(connection, sql, user.getAccount(), user.getPassword(), user.getEmail(), user.getId()); - } - List findAll() { final var sql = "select id, account, password, email from users"; diff --git a/app/src/main/java/com/techcourse/dao/UserHistoryDao.java b/app/src/main/java/com/techcourse/dao/UserHistoryDao.java index 0d59666b25..3afdb57016 100644 --- a/app/src/main/java/com/techcourse/dao/UserHistoryDao.java +++ b/app/src/main/java/com/techcourse/dao/UserHistoryDao.java @@ -3,8 +3,6 @@ import com.techcourse.domain.UserHistory; import org.springframework.jdbc.core.JdbcTemplate; -import java.sql.Connection; - public class UserHistoryDao { private final JdbcTemplate jdbcTemplate; @@ -25,18 +23,4 @@ public void log(final UserHistory userHistory) { userHistory.getCreateBy() ); } - - public void log(final Connection connection, final UserHistory userHistory) { - final var sql = "insert into user_history (user_id, account, password, email, created_at, created_by) values (?, ?, ?, ?, ?, ?)"; - - jdbcTemplate.executeWithConnection(connection, - sql, - userHistory.getUserId(), - userHistory.getAccount(), - userHistory.getPassword(), - userHistory.getEmail(), - userHistory.getCreatedAt(), - userHistory.getCreateBy() - ); - } } diff --git a/app/src/main/java/com/techcourse/service/AppUserService.java b/app/src/main/java/com/techcourse/service/AppUserService.java new file mode 100644 index 0000000000..2c9da4a526 --- /dev/null +++ b/app/src/main/java/com/techcourse/service/AppUserService.java @@ -0,0 +1,37 @@ +package com.techcourse.service; + +import com.techcourse.dao.UserDao; +import com.techcourse.dao.UserHistoryDao; +import com.techcourse.domain.User; +import com.techcourse.domain.UserHistory; + +public class AppUserService implements UserService { + + private final UserDao userDao; + private final UserHistoryDao userHistoryDao; + + public AppUserService(final UserDao userDao, final UserHistoryDao userHistoryDao) { + this.userDao = userDao; + this.userHistoryDao = userHistoryDao; + } + + @Override + public User findById(final long id) { + return userDao.findById(id) + .orElseThrow(() -> new IllegalArgumentException(String.format("id 값으로 해당하는 User 를 찾을 수 없습니다. 입력값 : %s", id))); + } + + @Override + public void insert(final User user) { + userDao.insert(user); + } + + @Override + public void changePassword(final long id, final String newPassword, final String createBy) { + final var user = findById(id); + user.changePassword(newPassword); + + userDao.update(user); + userHistoryDao.log(new UserHistory(user, createBy)); + } +} diff --git a/app/src/main/java/com/techcourse/service/TxUserService.java b/app/src/main/java/com/techcourse/service/TxUserService.java new file mode 100644 index 0000000000..a1c5bcb3ca --- /dev/null +++ b/app/src/main/java/com/techcourse/service/TxUserService.java @@ -0,0 +1,30 @@ +package com.techcourse.service; + +import com.techcourse.domain.User; +import org.springframework.transaction.support.TransactionTemplate; + +public class TxUserService implements UserService { + + private final UserService userService; + private final TransactionTemplate transactionTemplate; + + public TxUserService(final UserService userService, final TransactionTemplate transactionTemplate) { + this.userService = userService; + this.transactionTemplate = transactionTemplate; + } + + @Override + public User findById(final long id) { + return transactionTemplate.queryWithTransaction(() -> userService.findById(id)); + } + + @Override + public void insert(final User user) { + transactionTemplate.executeWithTransaction(() -> userService.insert(user)); + } + + @Override + public void changePassword(final long id, final String newPassword, final String createBy) { + transactionTemplate.executeWithTransaction(() -> userService.changePassword(id, newPassword, createBy)); + } +} diff --git a/app/src/main/java/com/techcourse/service/UserService.java b/app/src/main/java/com/techcourse/service/UserService.java index 08737eb6d9..b14dbcacbf 100644 --- a/app/src/main/java/com/techcourse/service/UserService.java +++ b/app/src/main/java/com/techcourse/service/UserService.java @@ -1,83 +1,12 @@ package com.techcourse.service; -import com.techcourse.config.DataSourceConfig; -import com.techcourse.dao.UserDao; -import com.techcourse.dao.UserHistoryDao; import com.techcourse.domain.User; -import com.techcourse.domain.UserHistory; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.dao.DataAccessException; -import javax.sql.DataSource; -import java.sql.Connection; -import java.sql.SQLException; -import java.util.NoSuchElementException; +public interface UserService { -public class UserService { + User findById(final long id); - private static final Logger log = LoggerFactory.getLogger(UserService.class); + void insert(final User user); - private final UserDao userDao; - private final UserHistoryDao userHistoryDao; - - UserService(final UserDao userDao, final UserHistoryDao userHistoryDao) { - this.userDao = userDao; - this.userHistoryDao = userHistoryDao; - } - - User findById(final long id) { - return userDao.findById(id) - .orElseThrow(() -> new NoSuchElementException("id 값으로 해당하는 User 를 찾을 수 없습니다.")); - } - - public void insert(final User user) { - userDao.insert(user); - } - - void changePassword(final long id, final String newPassword, final String createBy) { - final var user = findById(id); - user.changePassword(newPassword); - - Connection connection = null; - try { - final DataSource dataSource = DataSourceConfig.getInstance(); - connection = dataSource.getConnection(); - connection.setAutoCommit(false); - - userDao.update(connection, user); - userHistoryDao.log(connection, new UserHistory(user, createBy)); - - connection.commit(); - } catch (final SQLException | DataAccessException e) { - rollback(connection); - log.error("SQLException occurred"); - throw new DataAccessException(e); - } finally { - release(connection); - } - } - - private void rollback(final Connection connection) { - if (connection != null) { - try { - connection.rollback(); - } catch (final SQLException ex) { - log.error("rollback callback"); - throw new DataAccessException(ex); - } - } - } - - private void release(final Connection connection) { - if (connection != null) { - try { - connection.setAutoCommit(true); - connection.close(); - } catch (final SQLException e) { - log.error("Cannot close Connection"); - throw new DataAccessException(e); - } - } - } + void changePassword(final long id, final String newPassword, final String createBy); } diff --git a/app/src/test/java/com/techcourse/service/MockUserHistoryDao.java b/app/src/test/java/com/techcourse/service/MockUserHistoryDao.java index bec1932c88..5a5925ee61 100644 --- a/app/src/test/java/com/techcourse/service/MockUserHistoryDao.java +++ b/app/src/test/java/com/techcourse/service/MockUserHistoryDao.java @@ -5,8 +5,6 @@ import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; -import java.sql.Connection; - public class MockUserHistoryDao extends UserHistoryDao { MockUserHistoryDao(final JdbcTemplate jdbcTemplate) { @@ -14,7 +12,7 @@ public class MockUserHistoryDao extends UserHistoryDao { } @Override - public void log(final Connection connection, final UserHistory userHistory) { + public void log(final UserHistory userHistory) { throw new DataAccessException(); } } diff --git a/app/src/test/java/com/techcourse/service/UserServiceTest.java b/app/src/test/java/com/techcourse/service/UserServiceTest.java index 1438b90b4c..9bb83ded41 100644 --- a/app/src/test/java/com/techcourse/service/UserServiceTest.java +++ b/app/src/test/java/com/techcourse/service/UserServiceTest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.api.Test; import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.support.TransactionTemplate; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -33,7 +34,7 @@ void setUp() { @Test void testChangePassword() { - final var userService = new UserService(userDao, userHistoryDao); + final var userService = new AppUserService(userDao, userHistoryDao); final var newPassword = "qqqqq"; final var createBy = "gugu"; @@ -47,7 +48,10 @@ void testChangePassword() { @Test void testTransactionRollback() { // 트랜잭션 롤백 테스트를 위해 mock으로 교체 - final var userService = new UserService(userDao, mockUserHistoryDao); + // 애플리케이션 서비스 + final var appUserService = new AppUserService(userDao, mockUserHistoryDao); + // 트랜잭션 서비스 추상화 + final var userService = new TxUserService(appUserService, new TransactionTemplate(DataSourceConfig.getInstance())); final var newPassword = "newPassword"; final var createBy = "gugu"; diff --git a/jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java b/jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java index 2dca5b4436..939b4f7f0c 100644 --- a/jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java +++ b/jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java @@ -3,6 +3,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.datasource.DataSourceUtils; import javax.sql.DataSource; import java.sql.Connection; @@ -24,8 +25,8 @@ public JdbcTemplate(final DataSource dataSource) { } public void execute(final String sql, final Object... params) { + final Connection conn = DataSourceUtils.getConnection(dataSource); try ( - final Connection conn = dataSource.getConnection(); final PreparedStatement pstmt = conn.prepareStatement(sql) ) { log.debug("query : {}", sql); @@ -34,27 +35,13 @@ public void execute(final String sql, final Object... params) { pstmt.executeUpdate(); } catch (final SQLException e) { - log.error(e.getMessage(), e); - throw new DataAccessException(e); - } - } - - public void executeWithConnection(final Connection conn, final String sql, final Object... params) { - try (final PreparedStatement pstmt = conn.prepareStatement(sql)) { - log.debug("query : {}", sql); - - setCondition(params, pstmt); - - pstmt.executeUpdate(); - } catch (final SQLException e) { - log.error(e.getMessage(), e); throw new DataAccessException(e); } } public List query(final String sql, final RowMapper rowMapper) { + final Connection conn = DataSourceUtils.getConnection(dataSource); try ( - final Connection conn = dataSource.getConnection(); final PreparedStatement pstmt = conn.prepareStatement(sql); final ResultSet rs = pstmt.executeQuery() ) { @@ -69,14 +56,13 @@ public List query(final String sql, final RowMapper rowMapper) { return results; } catch (final SQLException e) { - log.error(e.getMessage(), e); throw new DataAccessException(e); } } public Optional queryForObject(final String sql, final RowMapper rowMapper, final Object... params) { + final Connection conn = DataSourceUtils.getConnection(dataSource); try ( - final Connection conn = dataSource.getConnection(); final PreparedStatement pstmt = getPreparedStatement(conn, sql, params); final ResultSet rs = pstmt.executeQuery() ) { @@ -89,7 +75,6 @@ public Optional queryForObject(final String sql, final RowMapper rowMa return Optional.empty(); } catch (final SQLException e) { - log.error(e.getMessage(), e); throw new DataAccessException(e); } } diff --git a/jdbc/src/main/java/org/springframework/jdbc/datasource/DataSourceUtils.java b/jdbc/src/main/java/org/springframework/jdbc/datasource/DataSourceUtils.java index 3c40bfec52..a8556731d1 100644 --- a/jdbc/src/main/java/org/springframework/jdbc/datasource/DataSourceUtils.java +++ b/jdbc/src/main/java/org/springframework/jdbc/datasource/DataSourceUtils.java @@ -12,7 +12,7 @@ public abstract class DataSourceUtils { private DataSourceUtils() {} - public static Connection getConnection(DataSource dataSource) throws CannotGetJdbcConnectionException { + public static Connection getConnection(final DataSource dataSource) throws CannotGetJdbcConnectionException { Connection connection = TransactionSynchronizationManager.getResource(dataSource); if (connection != null) { return connection; @@ -22,15 +22,15 @@ public static Connection getConnection(DataSource dataSource) throws CannotGetJd connection = dataSource.getConnection(); TransactionSynchronizationManager.bindResource(dataSource, connection); return connection; - } catch (SQLException ex) { + } catch (final SQLException ex) { throw new CannotGetJdbcConnectionException("Failed to obtain JDBC Connection", ex); } } - public static void releaseConnection(Connection connection, DataSource dataSource) { + public static void releaseConnection(final Connection connection, final DataSource dataSource) { try { connection.close(); - } catch (SQLException ex) { + } catch (final SQLException ex) { throw new CannotGetJdbcConnectionException("Failed to close JDBC Connection"); } } diff --git a/jdbc/src/main/java/org/springframework/transaction/TransactionException.java b/jdbc/src/main/java/org/springframework/transaction/TransactionException.java new file mode 100644 index 0000000000..6177ae60b0 --- /dev/null +++ b/jdbc/src/main/java/org/springframework/transaction/TransactionException.java @@ -0,0 +1,8 @@ +package org.springframework.transaction; + +public class TransactionException extends RuntimeException { + + public TransactionException(final String message) { + super(message); + } +} diff --git a/jdbc/src/main/java/org/springframework/transaction/support/TransactionCommandExecutor.java b/jdbc/src/main/java/org/springframework/transaction/support/TransactionCommandExecutor.java new file mode 100644 index 0000000000..eff42a6f38 --- /dev/null +++ b/jdbc/src/main/java/org/springframework/transaction/support/TransactionCommandExecutor.java @@ -0,0 +1,7 @@ +package org.springframework.transaction.support; + +@FunctionalInterface +public interface TransactionCommandExecutor { + + void run(); +} diff --git a/jdbc/src/main/java/org/springframework/transaction/support/TransactionQueryExecutor.java b/jdbc/src/main/java/org/springframework/transaction/support/TransactionQueryExecutor.java new file mode 100644 index 0000000000..25dbe12f12 --- /dev/null +++ b/jdbc/src/main/java/org/springframework/transaction/support/TransactionQueryExecutor.java @@ -0,0 +1,7 @@ +package org.springframework.transaction.support; + +@FunctionalInterface +public interface TransactionQueryExecutor { + + T get(); +} diff --git a/jdbc/src/main/java/org/springframework/transaction/support/TransactionSynchronizationManager.java b/jdbc/src/main/java/org/springframework/transaction/support/TransactionSynchronizationManager.java index 715557fc66..68cb8251e1 100644 --- a/jdbc/src/main/java/org/springframework/transaction/support/TransactionSynchronizationManager.java +++ b/jdbc/src/main/java/org/springframework/transaction/support/TransactionSynchronizationManager.java @@ -2,22 +2,40 @@ import javax.sql.DataSource; import java.sql.Connection; +import java.util.HashMap; import java.util.Map; public abstract class TransactionSynchronizationManager { - private static final ThreadLocal> resources = new ThreadLocal<>(); + private static ThreadLocal> resources; - private TransactionSynchronizationManager() {} + private TransactionSynchronizationManager() { + } + + public static Connection getResource(final DataSource key) { + if (resources == null) { + return null; + } - public static Connection getResource(DataSource key) { - return null; + return resources.get().get(key); } - public static void bindResource(DataSource key, Connection value) { + public static void bindResource(final DataSource key, final Connection value) { + if (resources == null) { + resources = ThreadLocal.withInitial(HashMap::new); + } + + final Map connections = resources.get(); + connections.put(key, value); } - public static Connection unbindResource(DataSource key) { - return null; + public static void unbindResource(final DataSource key) { + final Map connections = resources.get(); + + connections.remove(key); + + if (connections.isEmpty()) { + resources.remove(); + } } } diff --git a/jdbc/src/main/java/org/springframework/transaction/support/TransactionTemplate.java b/jdbc/src/main/java/org/springframework/transaction/support/TransactionTemplate.java new file mode 100644 index 0000000000..c6bdc5486f --- /dev/null +++ b/jdbc/src/main/java/org/springframework/transaction/support/TransactionTemplate.java @@ -0,0 +1,76 @@ +package org.springframework.transaction.support; + +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.datasource.DataSourceUtils; +import org.springframework.transaction.TransactionException; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; + +public class TransactionTemplate { + + private final DataSource dataSource; + + public TransactionTemplate(final DataSource dataSource) { + this.dataSource = dataSource; + } + + public void executeWithTransaction(final TransactionCommandExecutor transactionCommandExecutor) { + final Connection connection = DataSourceUtils.getConnection(dataSource); + + try { + connection.setAutoCommit(false); + + transactionCommandExecutor.run(); + + connection.commit(); + } catch (final SQLException | DataAccessException e) { + rollback(connection); + throw new DataAccessException(e); + } finally { + release(dataSource, connection); + } + } + + private void rollback(final Connection connection) { + if (connection == null) { + return; + } + + try { + connection.rollback(); + } catch (final SQLException ex) { + throw new DataAccessException(ex); + } + } + + private static void release(final DataSource dataSource, final Connection connection) { + try { + connection.setAutoCommit(true); + TransactionSynchronizationManager.unbindResource(dataSource); + DataSourceUtils.releaseConnection(connection, dataSource); + } catch (final SQLException e) { + throw new TransactionException("Failed to change auto commit to true"); + } + } + + public T queryWithTransaction(final TransactionQueryExecutor transactionQueryExecutor) { + final Connection connection = DataSourceUtils.getConnection(dataSource); + + try { + connection.setAutoCommit(false); + connection.setReadOnly(true); + + final T result = transactionQueryExecutor.get(); + + connection.commit(); + return result; + } catch (final SQLException | DataAccessException e) { + rollback(connection); + throw new DataAccessException(e); + } finally { + release(dataSource, connection); + } + } +} diff --git a/study/src/test/java/transaction/stage1/Stage1Test.java b/study/src/test/java/transaction/stage1/Stage1Test.java index 8c29944d4e..eb20b2176f 100644 --- a/study/src/test/java/transaction/stage1/Stage1Test.java +++ b/study/src/test/java/transaction/stage1/Stage1Test.java @@ -34,10 +34,10 @@ * Read phenomena | Dirty reads | Non-repeatable reads | Phantom reads * Isolation level | | | * -----------------|-------------|----------------------|-------------- - * Read Uncommitted | | | - * Read Committed | | | - * Repeatable Read | | | - * Serializable | | | + * Read Uncommitted | + | + | + + * Read Committed | - | + | + + * Repeatable Read | - | - | +, -(InnoDB에서는 발생 x) + * Serializable | - | - | - */ class Stage1Test { @@ -58,15 +58,14 @@ private void setUp(final DataSource dataSource) { * Read phenomena | Dirty reads * Isolation level | * -----------------|------------- - * Read Uncommitted | - * Read Committed | - * Repeatable Read | - * Serializable | + * Read Uncommitted | + + * Read Committed | - + * Repeatable Read | - + * Serializable | - */ @Test void dirtyReading() throws SQLException { setUp(createH2DataSource()); - // db에 새로운 연결(사용자A)을 받아와서 final var connection = dataSource.getConnection(); @@ -81,7 +80,7 @@ void dirtyReading() throws SQLException { final var subConnection = dataSource.getConnection(); // 적절한 격리 레벨을 찾는다. - final int isolationLevel = Connection.TRANSACTION_NONE; + final int isolationLevel = Connection.TRANSACTION_READ_COMMITTED; // 트랜잭션 격리 레벨을 설정한다. subConnection.setTransactionIsolation(isolationLevel); @@ -111,10 +110,10 @@ void dirtyReading() throws SQLException { * Read phenomena | Non-repeatable reads * Isolation level | * -----------------|--------------------- - * Read Uncommitted | - * Read Committed | - * Repeatable Read | - * Serializable | + * Read Uncommitted | + + * Read Committed | + + * Repeatable Read | - + * Serializable | - */ @Test void noneRepeatable() throws SQLException { @@ -130,7 +129,7 @@ void noneRepeatable() throws SQLException { connection.setAutoCommit(false); // 적절한 격리 레벨을 찾는다. - final int isolationLevel = Connection.TRANSACTION_NONE; + final int isolationLevel = Connection.TRANSACTION_REPEATABLE_READ; // 트랜잭션 격리 레벨을 설정한다. connection.setTransactionIsolation(isolationLevel); @@ -173,10 +172,10 @@ void noneRepeatable() throws SQLException { * Read phenomena | Phantom reads * Isolation level | * -----------------|-------------- - * Read Uncommitted | - * Read Committed | - * Repeatable Read | - * Serializable | + * Read Uncommitted | + + * Read Committed | + + * Repeatable Read | +, -(innodb에서는 발생하지 않음) + * Serializable | - */ @Test void phantomReading() throws SQLException { @@ -184,6 +183,7 @@ void phantomReading() throws SQLException { // testcontainer로 docker를 실행해서 mysql에 연결한다. final var mysql = new MySQLContainer<>(DockerImageName.parse("mysql:8.0.30")) .withLogConsumer(new Slf4jLogConsumer(log)); + mysql.withUrlParam("allowMultiQueries", "true"); mysql.start(); setUp(createMySQLDataSource(mysql)); @@ -197,7 +197,7 @@ void phantomReading() throws SQLException { connection.setAutoCommit(false); // 적절한 격리 레벨을 찾는다. - final int isolationLevel = Connection.TRANSACTION_NONE; + final int isolationLevel = Connection.TRANSACTION_SERIALIZABLE; // 트랜잭션 격리 레벨을 설정한다. connection.setTransactionIsolation(isolationLevel); @@ -256,10 +256,10 @@ private static DataSource createH2DataSource() { return jdbcDataSource; } - private void sleep(double seconds) { + private void sleep(final double seconds) { try { TimeUnit.MILLISECONDS.sleep((long) (seconds * 1000)); - } catch (InterruptedException ignored) { + } catch (final InterruptedException ignored) { } } } diff --git a/study/src/test/java/transaction/stage2/FirstUserService.java b/study/src/test/java/transaction/stage2/FirstUserService.java index 9a1d415c18..0031dcd650 100644 --- a/study/src/test/java/transaction/stage2/FirstUserService.java +++ b/study/src/test/java/transaction/stage2/FirstUserService.java @@ -8,12 +8,14 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronizationManager; +import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.Set; -import java.util.stream.Collectors; import java.util.stream.Stream; +import static java.util.stream.Collectors.toCollection; + @Service public class FirstUserService { @@ -24,7 +26,8 @@ public class FirstUserService { @Autowired public FirstUserService(final UserRepository userRepository, - final SecondUserService secondUserService) { + final SecondUserService secondUserService + ) { this.userRepository = userRepository; this.secondUserService = secondUserService; } @@ -66,7 +69,7 @@ public Set saveAndExceptionWithRequiredNew() { throw new RuntimeException(); } -// @Transactional(propagation = Propagation.REQUIRED) + @Transactional(propagation = Propagation.REQUIRED) public Set saveFirstTransactionWithSupports() { final var firstTransactionName = TransactionSynchronizationManager.getCurrentTransactionName(); userRepository.save(User.createTest()); @@ -77,7 +80,7 @@ public Set saveFirstTransactionWithSupports() { return of(firstTransactionName, secondTransactionName); } -// @Transactional(propagation = Propagation.REQUIRED) + @Transactional(propagation = Propagation.REQUIRED) public Set saveFirstTransactionWithMandatory() { final var firstTransactionName = TransactionSynchronizationManager.getCurrentTransactionName(); userRepository.save(User.createTest()); @@ -99,7 +102,7 @@ public Set saveFirstTransactionWithNotSupported() { return of(firstTransactionName, secondTransactionName); } - @Transactional(propagation = Propagation.REQUIRED) +// @Transactional(propagation = Propagation.REQUIRED) public Set saveFirstTransactionWithNested() { final var firstTransactionName = TransactionSynchronizationManager.getCurrentTransactionName(); userRepository.save(User.createTest()); @@ -110,7 +113,7 @@ public Set saveFirstTransactionWithNested() { return of(firstTransactionName, secondTransactionName); } - @Transactional(propagation = Propagation.REQUIRED) +// @Transactional(propagation = Propagation.REQUIRED) public Set saveFirstTransactionWithNever() { final var firstTransactionName = TransactionSynchronizationManager.getCurrentTransactionName(); userRepository.save(User.createTest()); @@ -124,7 +127,7 @@ public Set saveFirstTransactionWithNever() { private Set of(final String firstTransactionName, final String secondTransactionName) { return Stream.of(firstTransactionName, secondTransactionName) .filter(transactionName -> !Objects.isNull(transactionName)) - .collect(Collectors.toSet()); + .collect(toCollection(LinkedHashSet::new)); } private void logActualTransactionActive() { diff --git a/study/src/test/java/transaction/stage2/Stage2Test.java b/study/src/test/java/transaction/stage2/Stage2Test.java index e522bf4365..4db7d6c1e3 100644 --- a/study/src/test/java/transaction/stage2/Stage2Test.java +++ b/study/src/test/java/transaction/stage2/Stage2Test.java @@ -13,10 +13,10 @@ /** * 트랜잭션 전파(Transaction Propagation)란? * 트랜잭션의 경계에서 이미 진행 중인 트랜잭션이 있을 때 또는 없을 때 어떻게 동작할 것인가를 결정하는 방식을 말한다. - * + *

* FirstUserService 클래스의 메서드를 실행할 때 첫 번째 트랜잭션이 생성된다. * SecondUserService 클래스의 메서드를 실행할 때 두 번째 트랜잭션이 어떻게 되는지 관찰해보자. - * + *

* https://docs.spring.io/spring-framework/docs/current/reference/html/data-access.html#tx-propagation */ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -36,8 +36,8 @@ void tearDown() { } /** - * 생성된 트랜잭션이 몇 개인가? - * 왜 그런 결과가 나왔을까? + * 생성된 트랜잭션이 몇 개인가? 1 + * 왜 그런 결과가 나왔을까? Required는 트랜잭션이 없을 때만 새로 생긴다. */ @Test void testRequired() { @@ -45,13 +45,13 @@ void testRequired() { log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(1) + .containsExactly("transaction.stage2.FirstUserService.saveFirstTransactionWithRequired"); } /** - * 생성된 트랜잭션이 몇 개인가? - * 왜 그런 결과가 나왔을까? + * 생성된 트랜잭션이 몇 개인가? 2 + * 왜 그런 결과가 나왔을까? Reauired_New 옵션은 트랜잭션 존재 유무와 관계 없이 항생 새로 생긴다. */ @Test void testRequiredNew() { @@ -59,27 +59,32 @@ void testRequiredNew() { log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(2) + .containsExactly( + "transaction.stage2.FirstUserService.saveFirstTransactionWithRequiredNew", + "transaction.stage2.SecondUserService.saveSecondTransactionWithRequiresNew" + ); } /** * firstUserService.saveAndExceptionWithRequiredNew()에서 강제로 예외를 발생시킨다. * REQUIRES_NEW 일 때 예외로 인한 롤백이 발생하면서 어떤 상황이 발생하는 지 확인해보자. + * secondUserService에서 발생한 예외 때문에 롤백되지만 firstUserService는 롤백되지 않는다. */ @Test void testRequiredNewWithRollback() { - assertThat(firstUserService.findAll()).hasSize(-1); + assertThat(firstUserService.findAll()).isEmpty(); assertThatThrownBy(() -> firstUserService.saveAndExceptionWithRequiredNew()) .isInstanceOf(RuntimeException.class); - assertThat(firstUserService.findAll()).hasSize(-1); + assertThat(firstUserService.findAll()).hasSize(1); } /** * FirstUserService.saveFirstTransactionWithSupports() 메서드를 보면 @Transactional이 주석으로 되어 있다. * 주석인 상태에서 테스트를 실행했을 때와 주석을 해제하고 테스트를 실행했을 때 어떤 차이점이 있는지 확인해보자. + * 기존 Transaction이 있으면 새로 만들고 없으면 논리적 트랜잭션으로 동작 */ @Test void testSupports() { @@ -87,14 +92,16 @@ void testSupports() { log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(1) + .containsExactly("transaction.stage2.FirstUserService.saveFirstTransactionWithSupports"); } /** * FirstUserService.saveFirstTransactionWithMandatory() 메서드를 보면 @Transactional이 주석으로 되어 있다. * 주석인 상태에서 테스트를 실행했을 때와 주석을 해제하고 테스트를 실행했을 때 어떤 차이점이 있는지 확인해보자. + * 트랜잭션이 없으면 예외를 발생 * SUPPORTS와 어떤 점이 다른지도 같이 챙겨보자. + * 트랜잭션이 없으면 새로운 논리적 트랜잭션을 만드는 Support와 달리 Mandatory를 예외를 발생시킨다. */ @Test void testMandatory() { @@ -102,15 +109,15 @@ void testMandatory() { log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(1) + .containsExactly("transaction.stage2.FirstUserService.saveFirstTransactionWithMandatory"); } /** * 아래 테스트는 몇 개의 물리적 트랜잭션이 동작할까? * FirstUserService.saveFirstTransactionWithNotSupported() 메서드의 @Transactional을 주석 처리하자. - * 다시 테스트를 실행하면 몇 개의 물리적 트랜잭션이 동작할까? - * + * 다시 테스트를 실행하면 몇 개의 물리적 트랜잭션이 동작할까? simpleJparRepository 때문에 모두 2개씩 사용된다. + *

* 스프링 공식 문서에서 물리적 트랜잭션과 논리적 트랜잭션의 차이점이 무엇인지 찾아보자. */ @Test @@ -119,13 +126,15 @@ void testNotSupported() { log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(2) + .containsExactly("transaction.stage2.FirstUserService.saveFirstTransactionWithNotSupported", + "transaction.stage2.SecondUserService.saveSecondTransactionWithNotSupported"); } /** * 아래 테스트는 왜 실패할까? * FirstUserService.saveFirstTransactionWithNested() 메서드의 @Transactional을 주석 처리하면 어떻게 될까? + * 트랜잭션이 없으면 새롭게 시작하기 때문에 정상동작한다. */ @Test void testNested() { @@ -133,12 +142,13 @@ void testNested() { log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(1) + .containsExactly("transaction.stage2.SecondUserService.saveSecondTransactionWithNested"); } /** * 마찬가지로 @Transactional을 주석처리하면서 관찰해보자. + * NEVER는 트랜잭션이 없어야 정상동작한다. */ @Test void testNever() { @@ -146,7 +156,7 @@ void testNever() { log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(1) + .containsExactly("transaction.stage2.SecondUserService.saveSecondTransactionWithNever"); } }