diff --git a/app/src/main/java/com/techcourse/dao/UserDao.java b/app/src/main/java/com/techcourse/dao/UserDao.java index 623e19a0c4..03a9da78dd 100644 --- a/app/src/main/java/com/techcourse/dao/UserDao.java +++ b/app/src/main/java/com/techcourse/dao/UserDao.java @@ -1,6 +1,7 @@ package com.techcourse.dao; import com.techcourse.domain.User; +import java.sql.Connection; import java.util.List; import javax.sql.DataSource; import org.springframework.jdbc.core.JdbcTemplate; @@ -36,9 +37,14 @@ public void update(final User user) { jdbcTemplate.update(sql, user.getAccount(), user.getPassword(), user.getEmail(), user.getId()); } + public void update(final Connection connection, final User user) { + final String sql = "update users set account = ?, password = ?, email = ? where id = ?"; + jdbcTemplate.update(connection, sql, user.getAccount(), user.getPassword(), user.getEmail(), user.getId()); + } + public List findAll() { final String sql = "select id, account, password, email from users"; - return jdbcTemplate.query(sql, ROW_MAPPER); + return jdbcTemplate.queryForList(sql, ROW_MAPPER); } public User findById(final Long id) { diff --git a/app/src/main/java/com/techcourse/dao/UserHistoryDao.java b/app/src/main/java/com/techcourse/dao/UserHistoryDao.java index 4e7648db80..c40975a4f5 100644 --- a/app/src/main/java/com/techcourse/dao/UserHistoryDao.java +++ b/app/src/main/java/com/techcourse/dao/UserHistoryDao.java @@ -1,6 +1,7 @@ package com.techcourse.dao; import com.techcourse.domain.UserHistory; +import java.sql.Connection; import javax.sql.DataSource; import org.springframework.jdbc.core.JdbcTemplate; @@ -29,4 +30,19 @@ 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.update( + connection, + sql, + userHistory.getUserId(), + userHistory.getAccount(), + userHistory.getPassword(), + userHistory.getEmail(), + userHistory.getCreatedAt(), + userHistory.getCreateBy() + ); + } } diff --git a/app/src/main/java/com/techcourse/service/UserService.java b/app/src/main/java/com/techcourse/service/UserService.java index fcf2159dc8..292ac481d2 100644 --- a/app/src/main/java/com/techcourse/service/UserService.java +++ b/app/src/main/java/com/techcourse/service/UserService.java @@ -1,9 +1,14 @@ 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 java.sql.Connection; +import java.sql.SQLException; +import javax.sql.DataSource; +import org.springframework.dao.DataAccessException; public class UserService { @@ -23,10 +28,37 @@ 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)); + public void changePassword(final long id, final String newPassword, final String createBy) { + final DataSource dataSource = DataSourceConfig.getInstance(); + final Connection connection = getConnection(dataSource); + try (connection) { + connection.setAutoCommit(false); + + final User user = findById(id); + user.changePassword(newPassword); + userDao.update(connection, user); + userHistoryDao.log(connection, new UserHistory(user, createBy)); + + connection.commit(); + } catch (SQLException | DataAccessException e) { + rollback(connection); + throw new DataAccessException(e); + } + } + + private Connection getConnection(final DataSource dataSource) { + try { + return dataSource.getConnection(); + } catch (SQLException e) { + throw new DataAccessException(); + } + } + + private void rollback(final Connection connection) { + try { + connection.rollback(); + } catch (SQLException e) { + throw new DataAccessException(); + } } } diff --git a/app/src/test/java/com/techcourse/dao/UserDaoTest.java b/app/src/test/java/com/techcourse/dao/UserDaoTest.java index 773d7faf82..3e13e3635c 100644 --- a/app/src/test/java/com/techcourse/dao/UserDaoTest.java +++ b/app/src/test/java/com/techcourse/dao/UserDaoTest.java @@ -3,8 +3,10 @@ import com.techcourse.config.DataSourceConfig; import com.techcourse.domain.User; import com.techcourse.support.jdbc.init.DatabasePopulatorUtils; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; import static org.assertj.core.api.Assertions.assertThat; @@ -21,6 +23,12 @@ void setup() { userDao.insert(user); } + @AfterEach + void tearDown() { + final JdbcTemplate jdbcTemplate = new JdbcTemplate(DataSourceConfig.getInstance()); + jdbcTemplate.update("TRUNCATE TABLE users RESTART IDENTITY"); + } + @Test void findAll() { final var users = userDao.findAll(); diff --git a/app/src/test/java/com/techcourse/service/MockUserHistoryDao.java b/app/src/test/java/com/techcourse/service/MockUserHistoryDao.java index 2ee12b195f..a768d17b27 100644 --- a/app/src/test/java/com/techcourse/service/MockUserHistoryDao.java +++ b/app/src/test/java/com/techcourse/service/MockUserHistoryDao.java @@ -2,6 +2,7 @@ import com.techcourse.dao.UserHistoryDao; import com.techcourse.domain.UserHistory; +import java.sql.Connection; import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; @@ -15,4 +16,9 @@ public MockUserHistoryDao(final JdbcTemplate jdbcTemplate) { public void log(final UserHistory userHistory) { throw new DataAccessException(); } + + @Override + public void log(final Connection connection, 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 255a0ebfe7..2d4abce635 100644 --- a/app/src/test/java/com/techcourse/service/UserServiceTest.java +++ b/app/src/test/java/com/techcourse/service/UserServiceTest.java @@ -14,7 +14,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; -@Disabled class UserServiceTest { private JdbcTemplate jdbcTemplate; 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 72d5f65f9a..dcae16428c 100644 --- a/jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java +++ b/jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java @@ -6,7 +6,6 @@ import java.sql.SQLException; import java.util.ArrayList; import java.util.List; -import javax.annotation.Nullable; import javax.sql.DataSource; import org.springframework.dao.DataAccessException; @@ -23,9 +22,22 @@ public int update(final String sql, final Object... args) { } private T execute(final PreparedStatementCallback preparedStatementCallback, - final ExecutionCallback executionCallback) { - try (final Connection connection = dataSource.getConnection(); - final PreparedStatement pstmt = preparedStatementCallback.prepareStatement(connection)) { + final ExecutionCallback executionCallback) { + try (final Connection connection = dataSource.getConnection()) { + return executeWithConnection(connection, preparedStatementCallback, executionCallback); + } catch (SQLException e) { + throw new DataAccessException(e); + } + } + + public int update(final Connection connection, final String sql, final Object... args) { + return executeWithConnection(connection, conn -> prepareStatement(sql, conn, args), PreparedStatement::executeUpdate); + } + + private T executeWithConnection(final Connection connection, + final PreparedStatementCallback preparedStatementCallback, + final ExecutionCallback executionCallback) { + try (final PreparedStatement pstmt = preparedStatementCallback.prepareStatement(connection)) { return executionCallback.execute(pstmt); } catch (SQLException e) { throw new DataAccessException(e); @@ -45,22 +57,21 @@ private void setParameters(final PreparedStatement pstmt, final Object[] args) t } } - @Nullable public T queryForObject(final String sql, final RowMapper rowMapper, final Object... args) { - final List results = executeQuery(connection -> prepareStatement(sql, connection, args), rowMapper); + final List results = queryForList(sql, rowMapper, args); if (results.isEmpty()) { - return null; + throw new DataAccessException(); + } + if (results.size() > 1) { + throw new DataAccessException(); } - return results.get(0); + return results.iterator().next(); } - private List executeQuery( - final PreparedStatementCallback preparedStatementCallback, - final RowMapper rowMapper - ) { - return execute(preparedStatementCallback, pstmt -> { + public List queryForList(final String sql, final RowMapper rowMapper, final Object... args) { + return execute(connection -> prepareStatement(sql, connection, args), pstmt -> { try (final ResultSet rs = pstmt.executeQuery()) { - List results = new ArrayList<>(); + final List results = new ArrayList<>(); while (rs.next()) { results.add(rowMapper.mapRow(rs)); } @@ -68,8 +79,4 @@ private List executeQuery( } }); } - - public List query(final String sql, final RowMapper rowMapper, final Object... args) { - return executeQuery(connection -> prepareStatement(sql, connection, args), rowMapper); - } } diff --git a/study/src/test/java/transaction/stage1/Stage1Test.java b/study/src/test/java/transaction/stage1/Stage1Test.java index 8c29944d4e..27d33e7546 100644 --- a/study/src/test/java/transaction/stage1/Stage1Test.java +++ b/study/src/test/java/transaction/stage1/Stage1Test.java @@ -58,10 +58,10 @@ 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 { @@ -81,7 +81,7 @@ void dirtyReading() throws SQLException { final var subConnection = dataSource.getConnection(); // 적절한 격리 레벨을 찾는다. - final int isolationLevel = Connection.TRANSACTION_NONE; + final int isolationLevel = Connection.TRANSACTION_SERIALIZABLE; // 트랜잭션 격리 레벨을 설정한다. subConnection.setTransactionIsolation(isolationLevel); @@ -111,10 +111,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 +130,7 @@ void noneRepeatable() throws SQLException { connection.setAutoCommit(false); // 적절한 격리 레벨을 찾는다. - final int isolationLevel = Connection.TRANSACTION_NONE; + final int isolationLevel = Connection.TRANSACTION_SERIALIZABLE; // 트랜잭션 격리 레벨을 설정한다. connection.setTransactionIsolation(isolationLevel); @@ -154,7 +154,7 @@ void noneRepeatable() throws SQLException { sleep(0.5); // 사용자A가 다시 gugu 객체를 조회했다. - // 사용자B는 패스워드를 변경하고 아직 커밋하지 않았다. + // 사용자B는 패스워드를 변경하고 커밋헸다. final var actual = userDao.findByAccount(connection, "gugu"); // 트랜잭션 격리 레벨에 따라 아래 테스트가 통과한다. @@ -173,10 +173,10 @@ void noneRepeatable() throws SQLException { * Read phenomena | Phantom reads * Isolation level | * -----------------|-------------- - * Read Uncommitted | - * Read Committed | - * Repeatable Read | - * Serializable | + * Read Uncommitted | + + * Read Committed | + + * Repeatable Read | + + * Serializable | - */ @Test void phantomReading() throws SQLException { @@ -185,6 +185,7 @@ void phantomReading() throws SQLException { final var mysql = new MySQLContainer<>(DockerImageName.parse("mysql:8.0.30")) .withLogConsumer(new Slf4jLogConsumer(log)); mysql.start(); + mysql.withUrlParam("allowMultiQueries", "true"); 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_SERIALIZABLE; // 트랜잭션 격리 레벨을 설정한다. 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..bb359ccf5e 100644 --- a/study/src/test/java/transaction/stage2/FirstUserService.java +++ b/study/src/test/java/transaction/stage2/FirstUserService.java @@ -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/Stage2Test.java b/study/src/test/java/transaction/stage2/Stage2Test.java index e522bf4365..9a4ff4e580 100644 --- a/study/src/test/java/transaction/stage2/Stage2Test.java +++ b/study/src/test/java/transaction/stage2/Stage2Test.java @@ -36,8 +36,8 @@ void tearDown() { } /** - * 생성된 트랜잭션이 몇 개인가? - * 왜 그런 결과가 나왔을까? + * 생성된 트랜잭션이 몇 개인가? 1 + * 왜 그런 결과가 나왔을까? Second(Required)가 First(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 + * 왜 그런 결과가 나왔을까? Second(RequiredNew)가 First(Required)에 참여하지 않고 트랜잭션을 새로 생성 */ @Test void testRequiredNew() { @@ -59,8 +59,11 @@ void testRequiredNew() { log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(2) + .containsExactly( + "transaction.stage2.SecondUserService.saveSecondTransactionWithRequiresNew", + "transaction.stage2.FirstUserService.saveFirstTransactionWithRequiredNew" + ); } /** @@ -69,17 +72,18 @@ void testRequiredNew() { */ @Test void testRequiredNewWithRollback() { - assertThat(firstUserService.findAll()).hasSize(-1); + assertThat(firstUserService.findAll()).hasSize(0); assertThatThrownBy(() -> firstUserService.saveAndExceptionWithRequiredNew()) .isInstanceOf(RuntimeException.class); - assertThat(firstUserService.findAll()).hasSize(-1); + assertThat(firstUserService.findAll()).hasSize(1); } /** * FirstUserService.saveFirstTransactionWithSupports() 메서드를 보면 @Transactional이 주석으로 되어 있다. * 주석인 상태에서 테스트를 실행했을 때와 주석을 해제하고 테스트를 실행했을 때 어떤 차이점이 있는지 확인해보자. + * supports: 이미 시작된 트랜잭션이 있으면 참여하고, 없으면 트랜잭션없이 진행한다. */ @Test void testSupports() { @@ -87,14 +91,16 @@ void testSupports() { log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(1) + .containsExactly("transaction.stage2.FirstUserService.saveFirstTransactionWithSupports"); } /** * FirstUserService.saveFirstTransactionWithMandatory() 메서드를 보면 @Transactional이 주석으로 되어 있다. * 주석인 상태에서 테스트를 실행했을 때와 주석을 해제하고 테스트를 실행했을 때 어떤 차이점이 있는지 확인해보자. * SUPPORTS와 어떤 점이 다른지도 같이 챙겨보자. + * mandatory: 트랜잭션이 있으면 참여, 없으면 예외가 발생한다. + * IllegalTransactionStateException: No existing transaction found for transaction marked with propagation 'mandatory' */ @Test void testMandatory() { @@ -102,30 +108,43 @@ void testMandatory() { log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(1) + .containsExactly("transaction.stage2.FirstUserService.saveFirstTransactionWithMandatory"); } /** - * 아래 테스트는 몇 개의 물리적 트랜잭션이 동작할까? + * 아래 테스트는 몇 개의 물리적 트랜잭션이 동작할까? 주석 처리 안했을때(required, not supported) 논리적으로는 2개, 물리적으로 1개? * FirstUserService.saveFirstTransactionWithNotSupported() 메서드의 @Transactional을 주석 처리하자. - * 다시 테스트를 실행하면 몇 개의 물리적 트랜잭션이 동작할까? - * + * 다시 테스트를 실행하면 몇 개의 물리적 트랜잭션이 동작할까? 주석 처리 했을때(x, not supported) 논리적으로는 1개, 물리적으로는 0개? + * not supported: 현재 트랜잭션이 존재하는 경우 먼저 이를 일시 중지한 다음 트랜잭션 없이 실행한다. * 스프링 공식 문서에서 물리적 트랜잭션과 논리적 트랜잭션의 차이점이 무엇인지 찾아보자. + * 외부 트랜잭션 범위는 내부 트랜잭션 범위와 논리적으로 독립이지만, 동일한 물리적 트랜잭션에 매핑된다. */ @Test void testNotSupported() { + final var actual = firstUserService.saveFirstTransactionWithNotSupported(); log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(1) + .containsExactly("transaction.stage2.SecondUserService.saveSecondTransactionWithNotSupported"); } /** - * 아래 테스트는 왜 실패할까? + * 아래 테스트는 왜 실패할까? NestedTransactionNotSupportedException: JpaDialect does not support savepoints - check your JPA provider's capabilities + * + * JpaTransactionManager supports nested transactions via JDBC 3.0 Savepoints. + * The "nestedTransactionAllowed" flag defaults to false though, since nested transactions will just apply to the JDBC Connection, not to the JPA EntityManager and its cached entity objects and related context. + * You can manually set the flag to true if you want to use nested transactions for JDBC access code which participates in JPA transactions (provided that your JDBC driver supports Savepoints). + * ❗️Note that JPA itself does not support nested transactions! Hence, do not expect JPA access code to semantically participate in a nested transaction. + * * FirstUserService.saveFirstTransactionWithNested() 메서드의 @Transactional을 주석 처리하면 어떻게 될까? + * nested: 트랜잭션이 존재하는지 확인하고 존재하는 경우 저장 지점을 표시한다. + * 즉, 비즈니스 로직 실행에서 예외가 발생하면 트랜잭션이 이 저장 지점으로 롤백된다. + * 활성 트랜잭션이 없으면 REQUIRED 처럼 작동한다. + * nested는 Transaction이 존재하면 중첩 Transaction을 만드는 것이다. + * 중첩 Transaction은 먼저 시작된 부모의 commit과 rollback에는 영향을 받지만, 자신의 commit이나 rollback이 부모의 Transaction에는 영향을 주지 않는 특징이 있다. */ @Test void testNested() { @@ -133,12 +152,14 @@ void testNested() { log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(1) + .containsExactly("transaction.stage2.SecondUserService.saveSecondTransactionWithNested"); } /** * 마찬가지로 @Transactional을 주석처리하면서 관찰해보자. + * never: 활성 트랜잭션이 있으면 예외가 발생한다. + * IllegalTransactionStateException: Existing transaction found for transaction marked with propagation 'never' */ @Test void testNever() { @@ -146,7 +167,7 @@ void testNever() { log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(1) + .containsExactly("transaction.stage2.SecondUserService.saveSecondTransactionWithNever"); } }