diff --git a/app/src/main/java/com/techcourse/dao/UserDao.java b/app/src/main/java/com/techcourse/dao/UserDao.java index b87de536f9..d3b3dfa0a9 100644 --- a/app/src/main/java/com/techcourse/dao/UserDao.java +++ b/app/src/main/java/com/techcourse/dao/UserDao.java @@ -7,7 +7,6 @@ import org.springframework.jdbc.core.RowMapper; import java.util.List; -import java.util.Optional; public class UserDao { @@ -20,7 +19,7 @@ public class UserDao { rs.getString("email") ); - private JdbcTemplate jdbcTemplate; + private final JdbcTemplate jdbcTemplate; public UserDao(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; @@ -32,10 +31,10 @@ public void insert(User user) { jdbcTemplate.update(sql, user.getAccount(), user.getPassword(), user.getEmail()); } - public void update(User user) { - String sql = "update users set account = ?, password = ? where email = ?"; + public int update(User user) { + String sql = "update users set account = ?, password = ?, email = ? where id = ?"; log.info("[LOG] update user"); - jdbcTemplate.update(sql, user.getAccount(), user.getPassword(), user.getEmail()); + return jdbcTemplate.update(sql, user.getAccount(), user.getPassword(), user.getEmail(), user.getId()); } public List findAll() { @@ -44,13 +43,13 @@ public List findAll() { return jdbcTemplate.query(sql, USER_ROW_MAPPER); } - public Optional findById(Long id) { + public User findById(Long id) { String sql = "select id, account, password, email from users where id = ?"; log.info("[LOG] select user by id"); return jdbcTemplate.queryForObject(sql, USER_ROW_MAPPER, id); } - public Optional findByAccount(String account) { + public User findByAccount(String account) { String sql = "select id, account, password, email from users where account = ?"; log.info("[LOG] select user by account"); return jdbcTemplate.queryForObject(sql, USER_ROW_MAPPER, account); diff --git a/app/src/main/java/com/techcourse/dao/UserHistoryDao.java b/app/src/main/java/com/techcourse/dao/UserHistoryDao.java index edb4338caa..ae2fdd3f09 100644 --- a/app/src/main/java/com/techcourse/dao/UserHistoryDao.java +++ b/app/src/main/java/com/techcourse/dao/UserHistoryDao.java @@ -1,62 +1,31 @@ package com.techcourse.dao; import com.techcourse.domain.UserHistory; -import org.springframework.jdbc.core.JdbcTemplate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - -import javax.sql.DataSource; -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.SQLException; +import org.springframework.jdbc.core.JdbcTemplate; public class UserHistoryDao { private static final Logger log = LoggerFactory.getLogger(UserHistoryDao.class); - private final DataSource dataSource; - - public UserHistoryDao(final DataSource dataSource) { - this.dataSource = dataSource; - } + private final JdbcTemplate jdbcTemplate; public UserHistoryDao(final JdbcTemplate jdbcTemplate) { - this.dataSource = null; + this.jdbcTemplate = jdbcTemplate; } - public void log(final UserHistory userHistory) { + public void log(UserHistory userHistory) { final var sql = "insert into user_history (user_id, account, password, email, created_at, created_by) values (?, ?, ?, ?, ?, ?)"; - - Connection conn = null; - PreparedStatement pstmt = null; - try { - conn = dataSource.getConnection(); - pstmt = conn.prepareStatement(sql); - - log.debug("query : {}", sql); - - pstmt.setLong(1, userHistory.getUserId()); - pstmt.setString(2, userHistory.getAccount()); - pstmt.setString(3, userHistory.getPassword()); - pstmt.setString(4, userHistory.getEmail()); - pstmt.setObject(5, userHistory.getCreatedAt()); - pstmt.setString(6, userHistory.getCreateBy()); - pstmt.executeUpdate(); - } catch (SQLException e) { - log.error(e.getMessage(), e); - throw new RuntimeException(e); - } finally { - try { - if (pstmt != null) { - pstmt.close(); - } - } catch (SQLException ignored) {} - - try { - if (conn != null) { - conn.close(); - } - } catch (SQLException ignored) {} - } + log.info("[LOG] insert user into user_history"); + Object[] params = { + userHistory.getUserId(), + userHistory.getAccount(), + userHistory.getPassword(), + userHistory.getEmail(), + userHistory.getCreatedAt(), + userHistory.getCreateBy() + }; + jdbcTemplate.update(sql, params); } } 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..5fbfced7e7 --- /dev/null +++ b/app/src/main/java/com/techcourse/service/AppUserService.java @@ -0,0 +1,41 @@ +package com.techcourse.service; + +import com.techcourse.dao.UserDao; +import com.techcourse.dao.UserHistoryDao; +import com.techcourse.domain.User; +import com.techcourse.domain.UserHistory; +import org.springframework.dao.DataAccessException; + +public class AppUserService implements UserService { + + private final UserDao userDao; + private final UserHistoryDao userHistoryDao; + private static final int QUERY_SINGLE_SIZE = 1; + + public AppUserService(final UserDao userDao, final UserHistoryDao userHistoryDao) { + this.userDao = userDao; + this.userHistoryDao = userHistoryDao; + } + + public User findById(long id) { + return userDao.findById(id); + } + + public void insert(User user) { + userDao.insert(user); + } + + public void changePassword(long id, String newPassword, String createBy) { + User user = findById(id); + user.changePassword(newPassword); + updateUser(user); + userHistoryDao.log(new UserHistory(user, createBy)); + } + + private void updateUser(User user) { + int updateSize = userDao.update(user); + if (updateSize != QUERY_SINGLE_SIZE) { + throw new DataAccessException("갱신된 데이터의 개수가 올바르지 않습니다."); + } + } +} 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..41c97014d9 --- /dev/null +++ b/app/src/main/java/com/techcourse/service/TxUserService.java @@ -0,0 +1,36 @@ +package com.techcourse.service; + +import com.techcourse.domain.User; +import org.springframework.transaction.support.TransactionTemplate; + +public class TxUserService implements UserService { + + private final TransactionTemplate transactionTemplate; + private final UserService userService; + + public TxUserService(TransactionTemplate transactionTemplate, UserService userService) { + this.transactionTemplate = transactionTemplate; + this.userService = userService; + } + + @Override + public User findById(long id) { + return userService.findById(id); + } + + @Override + public void insert(User user) { + transactionTemplate.execute(() -> { + userService.insert(user); + return null; + }); + } + + @Override + public void changePassword(long id, String newPassword, String createBy) { + transactionTemplate.execute(() -> { + userService.changePassword(id, newPassword, createBy); + return null; + }); + } +} diff --git a/app/src/main/java/com/techcourse/service/UserService.java b/app/src/main/java/com/techcourse/service/UserService.java index fe00eb6ced..a054542fd2 100644 --- a/app/src/main/java/com/techcourse/service/UserService.java +++ b/app/src/main/java/com/techcourse/service/UserService.java @@ -1,33 +1,12 @@ 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 UserService { +public interface UserService { - private final UserDao userDao; - private final UserHistoryDao userHistoryDao; + User findById(long id); - public UserService(final UserDao userDao, final UserHistoryDao userHistoryDao) { - this.userDao = userDao; - this.userHistoryDao = userHistoryDao; - } + void insert(User user); - public User findById(final long id) { - return userDao.findById(id) - .orElseThrow(() -> new IllegalArgumentException("해당하는 유저가 없습니다.")); - } - - public void insert(final User user) { - userDao.insert(user); - } - - 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)); - } + void changePassword(long id, String newPassword, String createBy); } diff --git a/app/src/test/java/com/techcourse/dao/UserDaoTest.java b/app/src/test/java/com/techcourse/dao/UserDaoTest.java index 097bb7e536..c08bbcb67e 100644 --- a/app/src/test/java/com/techcourse/dao/UserDaoTest.java +++ b/app/src/test/java/com/techcourse/dao/UserDaoTest.java @@ -7,7 +7,10 @@ import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; +import javax.sql.DataSource; + import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; class UserDaoTest { @@ -15,10 +18,11 @@ class UserDaoTest { @BeforeEach void setup() { - DatabasePopulatorUtils.execute(DataSourceConfig.getInstance()); + DataSource dataSource = DataSourceConfig.getInstance(); + DatabasePopulatorUtils.execute(dataSource); - userDao = new UserDao(new JdbcTemplate(DataSourceConfig.getInstance())); final var user = new User("gugu", "password", "hkkang@woowahan.com"); + userDao = new UserDao(new JdbcTemplate(dataSource)); userDao.insert(user); } @@ -31,7 +35,7 @@ void findAll() { @Test void findById() { - final var user = userDao.findById(1L).get(); + final var user = userDao.findById(1L); assertThat(user.getAccount()).isEqualTo("gugu"); } @@ -39,18 +43,25 @@ void findById() { @Test void findByAccount() { final var account = "gugu"; - final var user = userDao.findByAccount(account).get(); + final var user = userDao.findByAccount(account); assertThat(user.getAccount()).isEqualTo(account); } + @Test + void findByWrongAccount() { + final var account = "gaga"; + assertThatThrownBy(() -> userDao.findByAccount(account)) + .isInstanceOf(RuntimeException.class); + } + @Test void insert() { final var account = "insert-gugu"; final var user = new User(account, "password", "hkkang@woowahan.com"); userDao.insert(user); - final var actual = userDao.findById(2L).get(); + final var actual = userDao.findById(2L); assertThat(actual.getAccount()).isEqualTo(account); } @@ -58,12 +69,12 @@ void insert() { @Test void update() { final var newPassword = "password99"; - final var user = userDao.findById(1L).get(); + final var user = userDao.findById(1L); user.changePassword(newPassword); userDao.update(user); - final var actual = userDao.findById(1L).get(); + final var actual = userDao.findById(1L); assertThat(actual.getPassword()).isEqualTo(newPassword); } diff --git a/app/src/test/java/com/techcourse/service/MockUserHistoryDao.java b/app/src/test/java/com/techcourse/service/MockUserHistoryDao.java index 2ee12b195f..0ab4810cc0 100644 --- a/app/src/test/java/com/techcourse/service/MockUserHistoryDao.java +++ b/app/src/test/java/com/techcourse/service/MockUserHistoryDao.java @@ -12,7 +12,7 @@ public MockUserHistoryDao(final JdbcTemplate jdbcTemplate) { } @Override - public void log(final UserHistory userHistory) { + public void log(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 255a0ebfe7..667b5d30ed 100644 --- a/app/src/test/java/com/techcourse/service/UserServiceTest.java +++ b/app/src/test/java/com/techcourse/service/UserServiceTest.java @@ -5,16 +5,15 @@ import com.techcourse.dao.UserHistoryDao; import com.techcourse.domain.User; import com.techcourse.support.jdbc.init.DatabasePopulatorUtils; -import org.springframework.dao.DataAccessException; -import org.springframework.jdbc.core.JdbcTemplate; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; 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; -@Disabled class UserServiceTest { private JdbcTemplate jdbcTemplate; @@ -26,20 +25,22 @@ void setUp() { this.userDao = new UserDao(jdbcTemplate); DatabasePopulatorUtils.execute(DataSourceConfig.getInstance()); - final var user = new User("gugu", "password", "hkkang@woowahan.com"); + var user = new User("gugu", "password", "hkkang@woowahan.com"); userDao.insert(user); } @Test void testChangePassword() { - final var userHistoryDao = new UserHistoryDao(jdbcTemplate); - final var userService = new UserService(userDao, userHistoryDao); + var userHistoryDao = new UserHistoryDao(jdbcTemplate); + var transactionTemplate = new TransactionTemplate(DataSourceConfig.getInstance()); + var appUserService = new AppUserService(userDao, userHistoryDao); + var userService = new TxUserService(transactionTemplate, appUserService); - final var newPassword = "qqqqq"; - final var createBy = "gugu"; + var newPassword = "qqqqq"; + var createBy = "gugu"; userService.changePassword(1L, newPassword, createBy); - final var actual = userService.findById(1L); + var actual = userService.findById(1L); assertThat(actual.getPassword()).isEqualTo(newPassword); } @@ -47,16 +48,20 @@ void testChangePassword() { @Test void testTransactionRollback() { // 트랜잭션 롤백 테스트를 위해 mock으로 교체 - final var userHistoryDao = new MockUserHistoryDao(jdbcTemplate); - final var userService = new UserService(userDao, userHistoryDao); + var userHistoryDao = new MockUserHistoryDao(jdbcTemplate); + var transactionTemplate = new TransactionTemplate(DataSourceConfig.getInstance()); + // 애플리케이션 서비스 + var appUserService = new AppUserService(userDao, userHistoryDao); + // 트랜잭션 서비스 추상화 + var userService = new TxUserService(transactionTemplate, appUserService); - final var newPassword = "newPassword"; - final var createBy = "gugu"; + var newPassword = "newPassword"; + var createBy = "gugu"; // 트랜잭션이 정상 동작하는지 확인하기 위해 의도적으로 MockUserHistoryDao에서 예외를 발생시킨다. assertThrows(DataAccessException.class, () -> userService.changePassword(1L, newPassword, createBy)); - final var actual = userService.findById(1L); + var actual = userService.findById(1L); assertThat(actual.getPassword()).isNotEqualTo(newPassword); } 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 8f5d251a91..2d3ee7e16d 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; @@ -11,67 +12,80 @@ import java.sql.SQLException; import java.util.ArrayList; import java.util.List; -import java.util.Optional; public class JdbcTemplate { private static Logger log = LoggerFactory.getLogger(JdbcTemplate.class); - private DataSource dataSource; + private final DataSource dataSource; public JdbcTemplate(DataSource dataSource) { this.dataSource = dataSource; } - public List query(String sql, RowMapper rowMapper, Object... args) { - try (Connection connection = dataSource.getConnection(); - PreparedStatement preparedStatement = getPreparedStatement(connection, sql, args); - ResultSet resultSet = preparedStatement.executeQuery()) { + public List query(String sql, RowMapper rowMapper, Object... params) { + return query(sql, rowMapper, createPreparedStatementSetter(params)); + } - List results = new ArrayList<>(); - while (resultSet.next()) { - results.add(rowMapper.run(resultSet)); - } + public T queryForObject(String sql, RowMapper rowMapper, Object... params) { + return queryForObject(sql, rowMapper, createPreparedStatementSetter(params)); + } - return results; + public int update(String sql, Object... params) { + return update(sql, createPreparedStatementSetter(params)); + } + + private List query(String sql, RowMapper rowMapper, PreparedStatementSetter pss) { + Connection connection = DataSourceUtils.getConnection(dataSource); + try (PreparedStatement preparedStatement = connection.prepareStatement(sql)) { + pss.setParameters(preparedStatement); + return mapResultSetToObject(rowMapper, preparedStatement); } catch (SQLException e) { log.error(e.getMessage(), e); throw new DataAccessException(e.getMessage(), e); } } - public Optional queryForObject(String sql, RowMapper rowMapper, Object... args) { - try (Connection connection = dataSource.getConnection(); - PreparedStatement preparedStatement = getPreparedStatement(connection, sql, args); - ResultSet resultSet = preparedStatement.executeQuery()) { - - if (!resultSet.next()) { - return Optional.empty(); - } + private T queryForObject(String sql, RowMapper rowMapper, PreparedStatementSetter pss) { + List result = query(sql, rowMapper, pss); + validateResultSize(result); + return result.get(0); + } - return Optional.of(rowMapper.run(resultSet)); + private int update(String sql, PreparedStatementSetter pss) { + try (Connection connection = dataSource.getConnection(); + PreparedStatement preparedStatement = connection.prepareStatement(sql)) { + pss.setParameters(preparedStatement); + return preparedStatement.executeUpdate(); } catch (SQLException e) { log.error(e.getMessage(), e); throw new DataAccessException(e.getMessage(), e); } } - public void update(String sql, Object... args) { - try (Connection connection = dataSource.getConnection(); - PreparedStatement preparedStatement = getPreparedStatement(connection, sql, args)) { - preparedStatement.executeUpdate(); + private PreparedStatementSetter createPreparedStatementSetter(Object... params) { + return psmt -> { + for (int i = 0; i < params.length; i++) { + psmt.setObject(i + 1, params[i]); + } + }; + } + + private List mapResultSetToObject(RowMapper rowMapper, PreparedStatement preparedStatement) { + try (ResultSet resultSet = preparedStatement.executeQuery()) { + List list = new ArrayList<>(); + while (resultSet.next()) { + list.add(rowMapper.run(resultSet)); + } + return list; } catch (SQLException e) { log.error(e.getMessage(), e); throw new DataAccessException(e.getMessage(), e); } } - private PreparedStatement getPreparedStatement(Connection connection, String sql, Object[] args) throws SQLException { - PreparedStatement preparedStatement = connection.prepareStatement(sql); - - for (int i = 0; i < args.length; i++) { - preparedStatement.setObject(i + 1, args[i]); + private static void validateResultSize(List result) { + if (result.isEmpty()) { + throw new DataAccessException("해당하는 유저가 없습니다."); } - - return preparedStatement; } } diff --git a/jdbc/src/main/java/org/springframework/jdbc/core/PreparedStatementSetter.java b/jdbc/src/main/java/org/springframework/jdbc/core/PreparedStatementSetter.java new file mode 100644 index 0000000000..e9a749d32a --- /dev/null +++ b/jdbc/src/main/java/org/springframework/jdbc/core/PreparedStatementSetter.java @@ -0,0 +1,8 @@ +package org.springframework.jdbc.core; + +import java.sql.PreparedStatement; +import java.sql.SQLException; + +public interface PreparedStatementSetter { + void setParameters(PreparedStatement pstmt) throws SQLException; +} 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..00cb517d17 100644 --- a/jdbc/src/main/java/org/springframework/jdbc/datasource/DataSourceUtils.java +++ b/jdbc/src/main/java/org/springframework/jdbc/datasource/DataSourceUtils.java @@ -29,7 +29,10 @@ public static Connection getConnection(DataSource dataSource) throws CannotGetJd public static void releaseConnection(Connection connection, DataSource dataSource) { try { - connection.close(); + if(!connection.getAutoCommit()){ + TransactionSynchronizationManager.unbindResource(dataSource); + connection.close(); + } } catch (SQLException ex) { throw new CannotGetJdbcConnectionException("Failed to close JDBC Connection"); } diff --git a/jdbc/src/main/java/org/springframework/transaction/support/TransactionExecutor.java b/jdbc/src/main/java/org/springframework/transaction/support/TransactionExecutor.java new file mode 100644 index 0000000000..db9421c270 --- /dev/null +++ b/jdbc/src/main/java/org/springframework/transaction/support/TransactionExecutor.java @@ -0,0 +1,7 @@ +package org.springframework.transaction.support; + +@FunctionalInterface +public interface TransactionExecutor { + + T execute(); +} 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..7ec7a245d8 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,32 @@ 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 final ThreadLocal> resources = ThreadLocal.withInitial(HashMap::new); - private TransactionSynchronizationManager() {} + private TransactionSynchronizationManager() { + } public static Connection getResource(DataSource key) { - return null; + Map map = getMap(); + return map.get(key); + } + + private static Map getMap() { + return resources.get(); } public static void bindResource(DataSource key, Connection value) { + Map map = resources.get(); + map.put(key, value); } - public static Connection unbindResource(DataSource key) { - return null; + public static void unbindResource(DataSource key) { + Map map = resources.get(); + map.remove(key); } } 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..f5894ad5a2 --- /dev/null +++ b/jdbc/src/main/java/org/springframework/transaction/support/TransactionTemplate.java @@ -0,0 +1,60 @@ +package org.springframework.transaction.support; + +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.datasource.DataSourceUtils; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; + +public class TransactionTemplate { + + private final DataSource dataSource; + + public TransactionTemplate(DataSource dataSource) { + this.dataSource = dataSource; + } + + public T execute(TransactionExecutor executor) { + Connection connection = startTransaction(); + try { + T result = executor.execute(); + commit(connection); + return result; + } catch (DataAccessException e) { + rollback(connection); + throw e; + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + TransactionSynchronizationManager.unbindResource(dataSource); + } + } + + private Connection startTransaction() { + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + connection.setAutoCommit(false); + } catch (SQLException e) { + throw new DataAccessException(e.getMessage(), e); + } + return connection; + } + + private void commit(Connection connection) { + try { + connection.commit(); + connection.setAutoCommit(true); + } catch (SQLException e) { + throw new DataAccessException(e.getMessage(), e); + } + } + + private void rollback(Connection connection) { + try { + connection.rollback(); + connection.setAutoCommit(true); + } catch (SQLException e) { + throw new DataAccessException(e.getMessage(), e); + } + } +} diff --git a/study/src/test/java/transaction/stage1/Stage1Test.java b/study/src/test/java/transaction/stage1/Stage1Test.java index 8c29944d4e..243a183cb6 100644 --- a/study/src/test/java/transaction/stage1/Stage1Test.java +++ b/study/src/test/java/transaction/stage1/Stage1Test.java @@ -32,12 +32,12 @@ * + : 발생 * - : 발생하지 않음 * Read phenomena | Dirty reads | Non-repeatable reads | Phantom reads - * Isolation level | | | - * -----------------|-------------|----------------------|-------------- - * Read Uncommitted | | | - * Read Committed | | | - * Repeatable Read | | | - * Serializable | | | + * Isolation level | | | + * -----------------|--------------|-----------------------|-------------- + * Read Uncommitted | 🅾️ | 🅾️ | 🅾️ + * Read Committed | ❎ | 🅾️ | 🅾️ + * Repeatable Read | ❎ | ❎ | 🅾️ + * Serializable | ❎ | ❎ | ❎ */ class Stage1Test { @@ -56,12 +56,12 @@ private void setUp(final DataSource dataSource) { * + : 발생 * - : 발생하지 않음 * Read phenomena | Dirty reads - * Isolation level | + * Isolation level | 커밋되지 않은 데이터를 조회할 수 있음 * -----------------|------------- - * Read Uncommitted | - * Read Committed | - * Repeatable Read | - * Serializable | + * Read Uncommitted | 🅾️️ + * Read Committed | ❎ + * Repeatable Read | ❎ + * Serializable | ❎ */ @Test void dirtyReading() throws SQLException { @@ -81,7 +81,7 @@ void dirtyReading() throws SQLException { final var subConnection = dataSource.getConnection(); // 적절한 격리 레벨을 찾는다. - final int isolationLevel = Connection.TRANSACTION_NONE; + final int isolationLevel = Connection.TRANSACTION_READ_UNCOMMITTED; // 트랜잭션 격리 레벨을 설정한다. subConnection.setTransactionIsolation(isolationLevel); @@ -109,12 +109,12 @@ void dirtyReading() throws SQLException { * + : 발생 * - : 발생하지 않음 * Read phenomena | Non-repeatable reads - * Isolation level | + * Isolation level | 한 트랜잭션 내에서 같은 데이터를 조회했을 때 다른 결과가 나올 수 있음 * -----------------|--------------------- - * Read Uncommitted | - * Read Committed | - * Repeatable Read | - * Serializable | + * Read Uncommitted | 🅾️ + * Read Committed | 🅾️ + * Repeatable Read | ❎ + * Serializable | ❎ */ @Test void noneRepeatable() throws SQLException { @@ -130,7 +130,7 @@ void noneRepeatable() throws SQLException { connection.setAutoCommit(false); // 적절한 격리 레벨을 찾는다. - final int isolationLevel = Connection.TRANSACTION_NONE; + final int isolationLevel = Connection.TRANSACTION_REPEATABLE_READ; // 트랜잭션 격리 레벨을 설정한다. connection.setTransactionIsolation(isolationLevel); @@ -151,7 +151,7 @@ void noneRepeatable() throws SQLException { userDao.update(subConnection, anotherUser); })).start(); - sleep(0.5); + sleep(0.5); //쓰레드 기다리기 위함 // 사용자A가 다시 gugu 객체를 조회했다. // 사용자B는 패스워드를 변경하고 아직 커밋하지 않았다. @@ -171,12 +171,12 @@ void noneRepeatable() throws SQLException { * + : 발생 * - : 발생하지 않음 * Read phenomena | Phantom reads - * Isolation level | + * Isolation level | 데이터의 추가 및 삭제가 일어나면 조회 결과가 달라질 수 있음 * -----------------|-------------- - * Read Uncommitted | - * Read Committed | - * Repeatable Read | - * Serializable | + * Read Uncommitted | 🅾️ + * Read Committed | 🅾️ + * Repeatable Read | 🅾️ + * Serializable | ❎ */ @Test void phantomReading() throws SQLException { @@ -184,6 +184,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 +198,7 @@ void phantomReading() throws SQLException { connection.setAutoCommit(false); // 적절한 격리 레벨을 찾는다. - final int isolationLevel = Connection.TRANSACTION_NONE; + final int isolationLevel = Connection.TRANSACTION_REPEATABLE_READ; // 트랜잭션 격리 레벨을 설정한다. connection.setTransactionIsolation(isolationLevel); diff --git a/study/src/test/java/transaction/stage2/FirstUserService.java b/study/src/test/java/transaction/stage2/FirstUserService.java index 9a1d415c18..a0d37b4bc6 100644 --- a/study/src/test/java/transaction/stage2/FirstUserService.java +++ b/study/src/test/java/transaction/stage2/FirstUserService.java @@ -46,19 +46,19 @@ public Set saveFirstTransactionWithRequired() { } @Transactional(propagation = Propagation.REQUIRED) - public Set saveFirstTransactionWithRequiredNew() { + public Set saveFirstTransactionWithRequiredNew(boolean throwException) { final var firstTransactionName = TransactionSynchronizationManager.getCurrentTransactionName(); userRepository.save(User.createTest()); logActualTransactionActive(); - final var secondTransactionName = secondUserService.saveSecondTransactionWithRequiresNew(); + final var secondTransactionName = secondUserService.saveSecondTransactionWithRequiresNew(throwException); return of(firstTransactionName, secondTransactionName); } @Transactional(propagation = Propagation.REQUIRED) - public Set saveAndExceptionWithRequiredNew() { - secondUserService.saveSecondTransactionWithRequiresNew(); + public Set saveAndExceptionWithRequiredNew(boolean throwException) { + secondUserService.saveSecondTransactionWithRequiresNew(throwException); userRepository.save(User.createTest()); logActualTransactionActive(); @@ -66,7 +66,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 +77,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()); @@ -88,7 +88,7 @@ public Set saveFirstTransactionWithMandatory() { return of(firstTransactionName, secondTransactionName); } - @Transactional(propagation = Propagation.REQUIRED) +// @Transactional(propagation = Propagation.REQUIRED) public Set saveFirstTransactionWithNotSupported() { final var firstTransactionName = TransactionSynchronizationManager.getCurrentTransactionName(); userRepository.save(User.createTest()); @@ -99,7 +99,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 +110,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()); diff --git a/study/src/test/java/transaction/stage2/SecondUserService.java b/study/src/test/java/transaction/stage2/SecondUserService.java index 0d240fe854..390f2893b4 100644 --- a/study/src/test/java/transaction/stage2/SecondUserService.java +++ b/study/src/test/java/transaction/stage2/SecondUserService.java @@ -26,9 +26,10 @@ public String saveSecondTransactionWithRequired() { } @Transactional(propagation = Propagation.REQUIRES_NEW) - public String saveSecondTransactionWithRequiresNew() { + public String saveSecondTransactionWithRequiresNew(boolean throwException) { userRepository.save(User.createTest()); logActualTransactionActive(); + if(!throwException) throw new RuntimeException(); return TransactionSynchronizationManager.getCurrentTransactionName(); } diff --git a/study/src/test/java/transaction/stage2/Stage2Test.java b/study/src/test/java/transaction/stage2/Stage2Test.java index e522bf4365..e4236e5bda 100644 --- a/study/src/test/java/transaction/stage2/Stage2Test.java +++ b/study/src/test/java/transaction/stage2/Stage2Test.java @@ -6,6 +6,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.IllegalTransactionStateException; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -36,8 +37,8 @@ void tearDown() { } /** - * 생성된 트랜잭션이 몇 개인가? - * 왜 그런 결과가 나왔을까? + * 생성된 트랜잭션이 몇 개인가? : propagetion이 required면 새로운 트랜잭션을 생성하지 않고 기존의 트랜잭션에 참여하기 때문에 1개의 트랜잭션만 생성된다. + * 왜 그런 결과가 나왔을까? : 트랜잭션의 이름은 메서드의 이름이 된다. */ @Test void testRequired() { @@ -45,71 +46,109 @@ void testRequired() { log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(1) + .containsExactly("transaction.stage2.FirstUserService.saveFirstTransactionWithRequired"); } /** - * 생성된 트랜잭션이 몇 개인가? - * 왜 그런 결과가 나왔을까? + * 생성된 트랜잭션이 몇 개인가? : propagation이 required_new면 호출할 때마다 새로운 트랜잭션을 생성하기 때문에 해당 테스트에선 2개의 트랜잭션이 생성된다. + * 왜 그런 결과가 나왔을까? : 트랜잭션의 이름은 메서드의 이름이 되는데, 트랜잭션이 두개니까 이름도 두개가 된다. */ @Test void testRequiredNew() { - final var actual = firstUserService.saveFirstTransactionWithRequiredNew(); + final var actual = firstUserService.saveFirstTransactionWithRequiredNew(true); log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(2) + .containsExactly("transaction.stage2.SecondUserService.saveSecondTransactionWithRequiresNew", + "transaction.stage2.FirstUserService.saveFirstTransactionWithRequiredNew"); } /** * firstUserService.saveAndExceptionWithRequiredNew()에서 강제로 예외를 발생시킨다. * REQUIRES_NEW 일 때 예외로 인한 롤백이 발생하면서 어떤 상황이 발생하는 지 확인해보자. + * :second 트랜잭션이 예외 발생으로 인해 rollback되면서 first 로직에 예외가 던져지는데 + * 여기서 예외에 대한 처리를 하지 않았기 때문에 rollback된다. 즉, 저장된 user는 없기 때문에 size가 0이다. */ @Test void testRequiredNewWithRollback() { - assertThat(firstUserService.findAll()).hasSize(-1); + assertThat(firstUserService.findAll()).hasSize(0); - assertThatThrownBy(() -> firstUserService.saveAndExceptionWithRequiredNew()) + assertThatThrownBy(() -> firstUserService.saveAndExceptionWithRequiredNew(false)) .isInstanceOf(RuntimeException.class); - assertThat(firstUserService.findAll()).hasSize(-1); + assertThat(firstUserService.findAll()).hasSize(0); } /** * FirstUserService.saveFirstTransactionWithSupports() 메서드를 보면 @Transactional이 주석으로 되어 있다. * 주석인 상태에서 테스트를 실행했을 때와 주석을 해제하고 테스트를 실행했을 때 어떤 차이점이 있는지 확인해보자. + * 1. 주석이 없는 경우 + * first에서 트랜잭션을 생성하지 않고 second를 호출함. second에선 @Transactional의 속성으로 supports를 지정했는데 + * 이는 이미 존재하는 트랜잭션이 있으면 참여하고 없으면 트랜잭션 없이 실행한다는 의미이다. + * 트랜잭션 없이 실행하지만 @Transactional 어노테이션이 붙은 이상 "논리 트랜잭션"이 만들어져 이름도 메서드로 지정되지만 + * 그 트랜잭션은 "물리 트랜잭션"이 아니라 활성화되진 않는다. + * 2. 주석이 있는 경우 + * first에서 트랜잭션을 생성하고 second를 호출함. second에선 @Transactional의 속성으로 supports를 지정했기 때문에 + * first에서 생성한 트랜잭션에 참여하게 된다. 따라서 first에서 생성한 트랜잭션의 이름이 second에서도 그대로 사용된다. + * */ @Test void testSupports() { final var actual = firstUserService.saveFirstTransactionWithSupports(); log.info("transactions : {}", actual); + // 주석이 없는 경우 +// assertThat(actual) +// .hasSize(1) +// .containsExactly("transaction.stage2.SecondUserService.saveSecondTransactionWithSupports"); + + // 주석이 있는 경우 assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(1) + .containsExactly("transaction.stage2.FirstUserService.saveFirstTransactionWithSupports"); } /** * FirstUserService.saveFirstTransactionWithMandatory() 메서드를 보면 @Transactional이 주석으로 되어 있다. * 주석인 상태에서 테스트를 실행했을 때와 주석을 해제하고 테스트를 실행했을 때 어떤 차이점이 있는지 확인해보자. * SUPPORTS와 어떤 점이 다른지도 같이 챙겨보자. + * 1. 주석이 없을 때 + * first에서 트랜잭션을 생성하지 않고 second를 호출함. second에선 @Transactional의 속성으로 mandatory를 지정했는데 + * 이는 이미 존재하는 트랜잭션이 있으면 참여하고 없으면 예외를 발생시킨다는 의미이다. + * 예외가 발생했기 때문에 first에서도 예외가 발생하고 테스트가 실패한다. + * 2. 주석이 있을 때 + * first에서 트랜잭션을 생성하고 second를 호출함. second에선 @Transactional의 속성으로 mandatory를 지정했기 때문에 + * first에서 생성한 트랜잭션에 참여하게 된다. 따라서 first에서 생성한 트랜잭션의 이름이 second에서도 그대로 사용된다. */ @Test void testMandatory() { + // 주석이 없을 때 +// assertThatThrownBy(() -> firstUserService.saveFirstTransactionWithMandatory()) +// .isInstanceOf(IllegalTransactionStateException.class); + + // 주석이 있을 때 final var actual = firstUserService.saveFirstTransactionWithMandatory(); log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(1) + .containsExactly("transaction.stage2.FirstUserService.saveFirstTransactionWithMandatory"); } /** * 아래 테스트는 몇 개의 물리적 트랜잭션이 동작할까? * FirstUserService.saveFirstTransactionWithNotSupported() 메서드의 @Transactional을 주석 처리하자. * 다시 테스트를 실행하면 몇 개의 물리적 트랜잭션이 동작할까? + * 1. FirstUserService.saveFirstTransactionWithNotSupported() 메서드의 @Transactional을 주석이 있을 때 + * first에서 트랜잭션을 생성하고 second를 호출함. second에선 @Transactional의 속성으로 not_supported를 지정했는데 + * 이는 이미 존재하는 트랜잭션이 있으면 일시 중단하고 없으면 트랜잭션 없이 실행한다는 의미이다. + * 따라서 first에서 생성한 트랜잭션을 일시 중단하고 second에서는 트랜잭션 없이 실행된다. + * 이때 second에선 @Transactional 어노테이션이 있기 때문에 이 자체로 "논리 트랜잭션"이 생성되지만 "물리 트랜잭션"은 생성되지 않는다. + * 2. FirstUserService.saveFirstTransactionWithNotSupported() 메서드의 @Transactional을 주석이 없을 때 + * first에서 트랜잭션없이 second를 호출함. first는 @Transactional 어노테이션이 없어 논리 및 물리 트랜잭션이 생성되지 않는다. + * second에선 @Transactional의 속성으로 not_supported를 지정했기 때문에 "논리 트랜잭션"이 생성되지만 "물리 트랜잭션"은 생성되지 않는다. * * 스프링 공식 문서에서 물리적 트랜잭션과 논리적 트랜잭션의 차이점이 무엇인지 찾아보자. */ @@ -118,14 +157,25 @@ void testNotSupported() { final var actual = firstUserService.saveFirstTransactionWithNotSupported(); log.info("transactions : {}", actual); + // FirstUserService.saveFirstTransactionWithNotSupported() 메서드의 @Transactional을 주석이 있을 때 +// assertThat(actual) +// .hasSize(2) +// .containsExactly("transaction.stage2.SecondUserService.saveSecondTransactionWithNotSupported", +// "transaction.stage2.FirstUserService.saveFirstTransactionWithNotSupported"); + // FirstUserService.saveFirstTransactionWithNotSupported() 메서드의 @Transactional을 주석이 없을 때 assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(1) + .containsExactly("transaction.stage2.SecondUserService.saveSecondTransactionWithNotSupported"); } /** * 아래 테스트는 왜 실패할까? + * 중첩 트랜잭션은 JDBC 3.0 이후 버전의 savepoint기능을 사용하는데, + * JPA를 사용하는 경우, 변경감지를 통해서 업데이트문을 최대한 지연해서 발행하는 방식을 사용하기 때문에 + * 중첩된 트랜잭션 경계를 설정할 수 없어 지원하지 않는다. * FirstUserService.saveFirstTransactionWithNested() 메서드의 @Transactional을 주석 처리하면 어떻게 될까? + * first에서 트랜잭션 없이 second를 호출함. first는 @Transactional 어노테이션이 없어 논리 및 물리 트랜잭션이 생성되지 않는다. + * second에선 @Transactional의 속성으로 nested를 지정했기 때문에 새로운 물리 트랜잭션을 생성한다. */ @Test void testNested() { @@ -133,20 +183,32 @@ void testNested() { log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(1) + .containsExactly("transaction.stage2.SecondUserService.saveSecondTransactionWithNested"); } /** * 마찬가지로 @Transactional을 주석처리하면서 관찰해보자. + * 1. FirstUserService.saveFirstTransactionWithNever() 메서드의 @Transactional을 주석이 있을 때 + * first에서 트랜잭션을 시작하고 second를 호출함. second에선 @Transactional의 속성으로 never를 지정했는데 + * 이는 이미 존재하는 트랜잭션이 있으면 예외를 발생시키고 없으면 트랜잭션 없이 실행한다는 의미이다. + * 따라서 first에서 생성한 트랜잭션이 존재하기 때문에 예외가 발생한다. + * 2. FirstUserService.saveFirstTransactionWithNever() 메서드의 @Transactional을 주석이 없을 때 + * first에서 트랜잭션 없이 second를 호출함. second에선 @Transactional의 속성으로 never를 지정했기 때문에 + * second에선 @Transactional의 속성으로 never를 지정했기 때문에 "논리 트랜잭션"이 생성되지만 "물리 트랜잭션"은 생성되지 않는다. */ @Test void testNever() { + // FirstUserService.saveFirstTransactionWithNever() 메서드의 @Transactional을 주석이 있을 때 +// assertThatThrownBy(() -> firstUserService.saveFirstTransactionWithNever()) +// .isInstanceOf(IllegalTransactionStateException.class); + + // IllegalTransactionStateException final var actual = firstUserService.saveFirstTransactionWithNever(); log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(1) + .containsExactly("transaction.stage2.SecondUserService.saveSecondTransactionWithNever"); } }