From e7816c98990e5364d0c20871c98f2fdc1039bbf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=A2=85=ED=99=94?= <77482065+jjongwa@users.noreply.github.com> Date: Sat, 7 Oct 2023 15:54:53 +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-=203?= =?UTF-8?q?=EB=8B=A8=EA=B3=84]=20=EB=94=94=EB=85=B8(=EC=8B=A0=EC=A2=85?= =?UTF-8?q?=ED=99=94)=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(#457)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * transaction 학습 테스트 Stage1 진행 * transaction 학습 테스트 Stage2 진행 * refactor: UserHistoryDao가 jdbcTemplate을 사용하도록 변경 * refactor: changePassword에 트랜잭션 적용 --- .../main/java/com/techcourse/dao/UserDao.java | 6 ++ .../com/techcourse/dao/UserHistoryDao.java | 69 +++++++------------ .../com/techcourse/service/UserService.java | 35 ++++++++-- .../service/MockUserHistoryDao.java | 3 +- .../techcourse/service/UserServiceTest.java | 1 - .../jdbc/core/AutoClosableTemplate.java | 8 +++ .../java/transaction/stage1/Stage1Test.java | 39 ++++++----- .../transaction/stage2/FirstUserService.java | 10 +-- .../java/transaction/stage2/Stage2Test.java | 48 ++++++++----- 9 files changed, 130 insertions(+), 89 deletions(-) diff --git a/app/src/main/java/com/techcourse/dao/UserDao.java b/app/src/main/java/com/techcourse/dao/UserDao.java index 2941d0b407..73ead66878 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.slf4j.Logger; @@ -41,6 +42,11 @@ public void update(final User user) { jdbcTemplate.execute(sql, user.getAccount(), user.getPassword(), user.getEmail(), user.getId()); } + public void update(final Connection conn, final User user) { + final var sql = "update users set account = ?, password = ?, email = ? where id = ?"; + jdbcTemplate.execute(conn, sql, user.getAccount(), user.getPassword(), user.getEmail(), user.getId()); + } + public List findAll() { final var sql = "select id, account, password, email from users"; return jdbcTemplate.query(sql, USER_ROW_MAPPER); diff --git a/app/src/main/java/com/techcourse/dao/UserHistoryDao.java b/app/src/main/java/com/techcourse/dao/UserHistoryDao.java index edb4338caa..483e5c650e 100644 --- a/app/src/main/java/com/techcourse/dao/UserHistoryDao.java +++ b/app/src/main/java/com/techcourse/dao/UserHistoryDao.java @@ -1,62 +1,45 @@ package com.techcourse.dao; import com.techcourse.domain.UserHistory; -import org.springframework.jdbc.core.JdbcTemplate; +import java.sql.Connection; +import javax.sql.DataSource; 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; + private final JdbcTemplate jdbcTemplate; public UserHistoryDao(final DataSource dataSource) { - this.dataSource = dataSource; + this.jdbcTemplate = new JdbcTemplate(dataSource); } public UserHistoryDao(final JdbcTemplate jdbcTemplate) { - this.dataSource = null; + this.jdbcTemplate = jdbcTemplate; } - public void log(final 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) {} - } + public void log(final Connection conn, final UserHistory userHistory) { + final var sql = "insert into user_history (" + + "user_id, " + + "account, " + + "password, " + + "email, " + + "created_at, " + + "created_by) " + + "values (?, ?, ?, ?, ?, ?)"; + + log.debug("query = {}", sql); + + jdbcTemplate.execute(conn, 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..0d997af2ea 100644 --- a/app/src/main/java/com/techcourse/service/UserService.java +++ b/app/src/main/java/com/techcourse/service/UserService.java @@ -1,9 +1,15 @@ 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; +import org.springframework.jdbc.datasource.DataSourceUtils; public class UserService { @@ -24,9 +30,30 @@ public void insert(final User 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)); + final DataSource dataSource = DataSourceConfig.getInstance(); + Connection conn = null; + try { + conn = dataSource.getConnection(); + conn.setAutoCommit(false); + + final User user = findById(id); + user.changePassword(newPassword); + + userDao.update(conn, user); + userHistoryDao.log(conn, new UserHistory(user, createBy)); + conn.commit(); + } catch (SQLException e) { + try { + conn.rollback(); + } catch (SQLException exception) { + throw new DataAccessException(exception); + } + } finally { + try { + conn.close(); + } catch (SQLException e) { + throw new DataAccessException(e); + } + } } } diff --git a/app/src/test/java/com/techcourse/service/MockUserHistoryDao.java b/app/src/test/java/com/techcourse/service/MockUserHistoryDao.java index 2ee12b195f..930200453d 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; @@ -12,7 +13,7 @@ public MockUserHistoryDao(final JdbcTemplate jdbcTemplate) { } @Override - public void log(final UserHistory userHistory) { + public void log(final Connection conn, 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/AutoClosableTemplate.java b/jdbc/src/main/java/org/springframework/jdbc/core/AutoClosableTemplate.java index c963fd58f0..9c801c220f 100644 --- a/jdbc/src/main/java/org/springframework/jdbc/core/AutoClosableTemplate.java +++ b/jdbc/src/main/java/org/springframework/jdbc/core/AutoClosableTemplate.java @@ -31,6 +31,14 @@ public void execute(final String sql, final Object... objects) { } } + public void execute(final Connection conn, final String sql, final Object... objects) { + try (final PreparedStatement pstmt = preparedStatementAndSetValue(conn, sql, objects)) { + commandQuery(pstmt); + } catch (SQLException e) { + throw new DataAccessException(e); + } + } + public List query(final String sql, final RowMapper rowMapper, final Object... objects) { try (final Connection conn = dataSource.getConnection(); final PreparedStatement pstmt = preparedStatementAndSetValue(conn, sql, objects) diff --git a/study/src/test/java/transaction/stage1/Stage1Test.java b/study/src/test/java/transaction/stage1/Stage1Test.java index 8c29944d4e..bf659afd1b 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 | - | - | + + * Serializable | - | - | - */ class Stage1Test { @@ -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 { @@ -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); @@ -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 { @@ -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_SERIALIZABLE; // 트랜잭션 격리 레벨을 설정한다. connection.setTransactionIsolation(isolationLevel); @@ -249,7 +250,7 @@ private static DataSource createMySQLDataSource(final JdbcDatabaseContainer c private static DataSource createH2DataSource() { final var jdbcDataSource = new JdbcDataSource(); // h2 로그를 확인하고 싶을 때 사용 -// jdbcDataSource.setUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;TRACE_LEVEL_SYSTEM_OUT=3;MODE=MYSQL"); + jdbcDataSource.setUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;TRACE_LEVEL_SYSTEM_OUT=3;MODE=MYSQL"); jdbcDataSource.setUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=MYSQL;"); jdbcDataSource.setUser("sa"); jdbcDataSource.setPassword(""); diff --git a/study/src/test/java/transaction/stage2/FirstUserService.java b/study/src/test/java/transaction/stage2/FirstUserService.java index 9a1d415c18..4ea7fef8b1 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..429c74b980 100644 --- a/study/src/test/java/transaction/stage2/Stage2Test.java +++ b/study/src/test/java/transaction/stage2/Stage2Test.java @@ -38,6 +38,7 @@ void tearDown() { /** * 생성된 트랜잭션이 몇 개인가? * 왜 그런 결과가 나왔을까? + * Propagation.REQUIRED(default): 이미 시작된 트랜잭션이 있으면 참여하고 없으면 새로 시작한다. */ @Test void testRequired() { @@ -45,13 +46,14 @@ void testRequired() { log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(1) + .containsExactly("transaction.stage2.FirstUserService.saveFirstTransactionWithRequired"); } /** * 생성된 트랜잭션이 몇 개인가? * 왜 그런 결과가 나왔을까? + * Propagation.REQUIRES_NEW: 항상 새로운 트랜잭션을 시작한다. 이미 진행 중인 트랜잭션이 있으면 트랜잭션을 잠시 보류시킨다. */ @Test void testRequiredNew() { @@ -59,8 +61,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 +74,19 @@ 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이 주석으로 되어 있다. * 주석인 상태에서 테스트를 실행했을 때와 주석을 해제하고 테스트를 실행했을 때 어떤 차이점이 있는지 확인해보자. + * 주석인 상태면 secondService의 SecondTransaction + * 주석 해제하면 firstServicedml FirstTransaction */ @Test void testSupports() { @@ -87,14 +94,17 @@ void testSupports() { log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(1) + .containsExactly("transaction.stage2.FirstUserService.saveFirstTransactionWithSupports"); } /** * FirstUserService.saveFirstTransactionWithMandatory() 메서드를 보면 @Transactional이 주석으로 되어 있다. * 주석인 상태에서 테스트를 실행했을 때와 주석을 해제하고 테스트를 실행했을 때 어떤 차이점이 있는지 확인해보자. + * 주석인 상태에서 테스트를 실행하면 -> 안돼유 * SUPPORTS와 어떤 점이 다른지도 같이 챙겨보자. + * MANDATORY: REQUIRED와 비슷하게 이미 시작된 트랜잭션이 있으면 참여한다. 반면에 트랜잭션이 시작된 것이 없으면 새로 시작하는 대신 예외를 발생시킨다. + * 혼자서는 독립적으로 트랜잭션을 진행하면 안 되는 경우에 사용한다. */ @Test void testMandatory() { @@ -102,16 +112,20 @@ void testMandatory() { log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(1) + .containsExactly("transaction.stage2.FirstUserService.saveFirstTransactionWithMandatory"); } /** * 아래 테스트는 몇 개의 물리적 트랜잭션이 동작할까? + * A: 2개 * FirstUserService.saveFirstTransactionWithNotSupported() 메서드의 @Transactional을 주석 처리하자. * 다시 테스트를 실행하면 몇 개의 물리적 트랜잭션이 동작할까? + * A: 1개 * * 스프링 공식 문서에서 물리적 트랜잭션과 논리적 트랜잭션의 차이점이 무엇인지 찾아보자. + * 물리적 트랜잭션: 실제 jdbc 트랜잭션 + * 논리적 트랜잭션: @Transactional로 중첩된 메서드 */ @Test void testNotSupported() { @@ -119,13 +133,15 @@ void testNotSupported() { 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 * FirstUserService.saveFirstTransactionWithNested() 메서드의 @Transactional을 주석 처리하면 어떻게 될까? + * 됩니다. */ @Test void testNested() { @@ -133,8 +149,8 @@ void testNested() { log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(1) + .containsExactly("transaction.stage2.SecondUserService.saveSecondTransactionWithNested"); } /** @@ -146,7 +162,7 @@ void testNever() { log.info("transactions : {}", actual); assertThat(actual) - .hasSize(0) - .containsExactly(""); + .hasSize(1) + .containsExactly("transaction.stage2.SecondUserService.saveSecondTransactionWithNever"); } }